From 9a43f7746497e491fcfcced4e455ec6a6f74932e Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 24 Dec 2023 11:52:19 +0900 Subject: [PATCH 001/148] Create src directory --- .coveragerc | 6 -- .github/workflows/ci.yml | 47 ++++++++++ .github/workflows/docs.yml | 25 +++++ .gitignore | 79 +++++++++++++--- .travis.yml | 28 ------ .vscode/settings.json | 4 + LICENSE | 21 ----- LICENSE.txt | 9 ++ MANIFEST.in | 5 - README.md | 13 +-- appveyor.yml | 25 ----- pyproject.toml | 90 ++++++++++++++++++ requirements.txt | 7 -- setup.cfg | 19 ---- setup.py | 91 ------------------- src/mkapi/__about__.py | 1 + {mkapi => src/mkapi}/__init__.py | 0 {mkapi => src/mkapi}/core/__init__.py | 0 {mkapi => src/mkapi}/core/attribute.py | 0 {mkapi => src/mkapi}/core/base.py | 0 {mkapi => src/mkapi}/core/code.py | 0 {mkapi => src/mkapi}/core/docstring.py | 0 {mkapi => src/mkapi}/core/inherit.py | 0 {mkapi => src/mkapi}/core/linker.py | 0 {mkapi => src/mkapi}/core/module.py | 0 {mkapi => src/mkapi}/core/node.py | 0 {mkapi => src/mkapi}/core/object.py | 0 {mkapi => src/mkapi}/core/page.py | 0 {mkapi => src/mkapi}/core/postprocess.py | 0 {mkapi => src/mkapi}/core/preprocess.py | 0 {mkapi => src/mkapi}/core/regex.py | 0 {mkapi => src/mkapi}/core/renderer.py | 0 {mkapi => src/mkapi}/core/signature.py | 68 +++++++------- {mkapi => src/mkapi}/core/structure.py | 0 {mkapi => src/mkapi}/main.py | 0 {mkapi => src/mkapi}/plugins/__init__.py | 0 {mkapi => src/mkapi}/plugins/api.py | 0 {mkapi => src/mkapi}/plugins/mkdocs.py | 0 {mkapi => src/mkapi}/templates/bases.jinja2 | 0 {mkapi => src/mkapi}/templates/code.jinja2 | 0 .../mkapi}/templates/docstring.jinja2 | 0 {mkapi => src/mkapi}/templates/items.jinja2 | 0 {mkapi => src/mkapi}/templates/macros.jinja2 | 0 {mkapi => src/mkapi}/templates/member.jinja2 | 0 {mkapi => src/mkapi}/templates/module.jinja2 | 0 {mkapi => src/mkapi}/templates/node.jinja2 | 0 {mkapi => src/mkapi}/templates/object.jinja2 | 0 .../mkapi}/theme/css/mkapi-common.css | 0 .../mkapi}/theme/css/mkapi-ivory.css | 0 .../mkapi}/theme/css/mkapi-mkdocs.css | 0 .../mkapi}/theme/css/mkapi-readthedocs.css | 0 {mkapi => src/mkapi}/theme/js/mkapi.js | 0 {mkapi => src/mkapi}/theme/mkapi.yml | 0 {mkapi => src/mkapi}/utils.py | 4 +- tests/__init__.py | 0 tests/core/test_core_module.py | 8 +- 56 files changed, 285 insertions(+), 265 deletions(-) delete mode 100644 .coveragerc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 .travis.yml create mode 100644 .vscode/settings.json delete mode 100644 LICENSE create mode 100644 LICENSE.txt delete mode 100644 MANIFEST.in delete mode 100644 appveyor.yml create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/mkapi/__about__.py rename {mkapi => src/mkapi}/__init__.py (100%) rename {mkapi => src/mkapi}/core/__init__.py (100%) rename {mkapi => src/mkapi}/core/attribute.py (100%) rename {mkapi => src/mkapi}/core/base.py (100%) rename {mkapi => src/mkapi}/core/code.py (100%) rename {mkapi => src/mkapi}/core/docstring.py (100%) rename {mkapi => src/mkapi}/core/inherit.py (100%) rename {mkapi => src/mkapi}/core/linker.py (100%) rename {mkapi => src/mkapi}/core/module.py (100%) rename {mkapi => src/mkapi}/core/node.py (100%) rename {mkapi => src/mkapi}/core/object.py (100%) rename {mkapi => src/mkapi}/core/page.py (100%) rename {mkapi => src/mkapi}/core/postprocess.py (100%) rename {mkapi => src/mkapi}/core/preprocess.py (100%) rename {mkapi => src/mkapi}/core/regex.py (100%) rename {mkapi => src/mkapi}/core/renderer.py (100%) rename {mkapi => src/mkapi}/core/signature.py (89%) rename {mkapi => src/mkapi}/core/structure.py (100%) rename {mkapi => src/mkapi}/main.py (100%) rename {mkapi => src/mkapi}/plugins/__init__.py (100%) rename {mkapi => src/mkapi}/plugins/api.py (100%) rename {mkapi => src/mkapi}/plugins/mkdocs.py (100%) rename {mkapi => src/mkapi}/templates/bases.jinja2 (100%) rename {mkapi => src/mkapi}/templates/code.jinja2 (100%) rename {mkapi => src/mkapi}/templates/docstring.jinja2 (100%) rename {mkapi => src/mkapi}/templates/items.jinja2 (100%) rename {mkapi => src/mkapi}/templates/macros.jinja2 (100%) rename {mkapi => src/mkapi}/templates/member.jinja2 (100%) rename {mkapi => src/mkapi}/templates/module.jinja2 (100%) rename {mkapi => src/mkapi}/templates/node.jinja2 (100%) rename {mkapi => src/mkapi}/templates/object.jinja2 (100%) rename {mkapi => src/mkapi}/theme/css/mkapi-common.css (100%) rename {mkapi => src/mkapi}/theme/css/mkapi-ivory.css (100%) rename {mkapi => src/mkapi}/theme/css/mkapi-mkdocs.css (100%) rename {mkapi => src/mkapi}/theme/css/mkapi-readthedocs.css (100%) rename {mkapi => src/mkapi}/theme/js/mkapi.js (100%) rename {mkapi => src/mkapi}/theme/mkapi.yml (100%) rename {mkapi => src/mkapi}/utils.py (96%) create mode 100644 tests/__init__.py diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index cf44ad16..00000000 --- a/.coveragerc +++ /dev/null @@ -1,6 +0,0 @@ -[report] -exclude_lines = - pragma: no cover - raise NotImplementedError - except ImportError: - pass diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b7a2c5a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: test-${{ github.head_ref }} + cancel-in-progress: true + +env: + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + +jobs: + run: + name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] # , windows-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Install Linux dependencies + if: startsWith(runner.os, 'Linux') + run: | + sudo apt-get update + sudo apt-get install texlive-plain-generic inkscape texlive-xetex latexmk + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Hatch + run: pip install --upgrade hatch + - name: Run static analysis + run: hatch fmt --check + - name: Run tests + run: hatch run test + - name: Upload Codecov Results + if: success() + uses: codecov/codecov-action@v3 + with: + file: lcov.info \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..8a080d3b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,25 @@ +name: Documentation +on: + push: + branches: [main] + tags: ['*'] +permissions: + contents: write +jobs: + deploy: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Install Hatch + run: pip install --upgrade hatch + - name: Deploy documentation + run: hatch run docs:deploy diff --git a/.gitignore b/.gitignore index 9782fb7a..3a5297ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,72 @@ -*.pyc +# Byte-compiled / optimized / DLL files __pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ build/ +develop-eggs/ dist/ -*.egg-info/ +downloads/ +eggs/ .eggs/ -.coverage.* -.coverage +lib/ +lib64/ +node_modules/ +parts/ +sdist/ +var/ +package*.json +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports htmlcov/ -.ropeproject/ -.pytest_cache/ -.mypy_cache/ -tests/docs/site/ -.pheasant_cache/ -notebooks/ -.env/ -.venv/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo + +# Scrapy stuff: +.scrapy + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# virtualenv +venv/ +ENV/ + +# MkDocs documentation +site*/ + +lcov.info \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5e4105c2..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: python -dist: xenial - -matrix: - include: - - os: linux - python: 3.7 - - os: linux - python: 3.8 - -install: - - pip install pycodestyle pyflakes mypy - - pip install pytest==5.4.2 pytest-cov coveralls mkdocs - - pip install jupyter pheasant mkdocs-ivory pymdown-extensions - - pip install -e . - -before_script: - - pycodestyle mkapi - - pyflakes mkapi - - mypy mkapi - - pycodestyle tests - - pyflakes tests - -script: - - pytest - -after_success: - - coveralls diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..58b50288 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false +} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e7ea6b74..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 daizutabi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..2f8c4dc3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2020-present daizutabi + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 5d5817c5..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include README.md -include LICENSE -recursive-include mkapi *.js *.css *.yml *.jinja2 -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/README.md b/README.md index 8a4f06d0..0a8d684a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ +# MkAPI + [![PyPI version][pypi-image]][pypi-link] [![Python versions][pyversions-image]][pyversions-link] -[![Travis][travis-image]][travis-link] -[![AppVeyor][appveyor-image]][appveyor-link] -[![Coverage Status][coveralls-image]][coveralls-link] [![Code style: black][black-image]][black-link] -# MkApi - MkApi plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkApi supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). Features of MkApi are: @@ -85,12 +82,6 @@ For more details, see [Page Mode and Internal Links](https://mkapi.daizutabi.net [pypi-image]: https://badge.fury.io/py/mkapi.svg [pypi-link]: https://pypi.org/project/mkapi -[travis-image]: https://travis-ci.org/daizutabi/mkapi.svg?branch=master -[travis-link]: https://travis-ci.org/daizutabi/mkapi -[appveyor-image]: https://ci.appveyor.com/api/projects/status/ys2ic8n4j7r5j4bg/branch/master?svg=true -[appveyor-link]: https://ci.appveyor.com/project/daizutabi/mkapi -[coveralls-image]: https://coveralls.io/repos/github/daizutabi/mkapi/badge.svg?branch=master -[coveralls-link]: https://coveralls.io/github/daizutabi/mkapi?branch=master [black-image]: https://img.shields.io/badge/code%20style-black-000000.svg [black-link]: https://github.com/ambv/black [pyversions-image]: https://img.shields.io/pypi/pyversions/mkapi.svg diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 52f576b5..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,25 +0,0 @@ -build: false - -environment: - matrix: - - PYTHON_VERSION: 3.7 - MINICONDA: C:\Miniconda37-x64 - # - PYTHON_VERSION: 3.8 - # MINICONDA: C:\Miniconda37-x64 - -init: - - "ECHO %PYTHON_VERSION% %MINICONDA%" - -install: - - "set PATH=%MINICONDA%;%MINICONDA%\\Scripts;%PATH%" - - conda config --set always_yes yes --set changeps1 no - - conda update -q conda - - conda info -a - - conda create -q -n test-environment python=%PYTHON_VERSION% - - activate test-environment - - pip install pytest pytest-cov mkdocs - - pip install jupyter pheasant mkdocs-ivory pymdown-extensions - - pip install -e . - -test_script: - - pytest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..84673752 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mkapi" +description = "A documentation generation tool for MkDocs" +readme = "README.md" +license = "MIT" +authors = [{ name = "daizutabi", email = "daizutabi@gmail.com" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: MkDocs", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Topic :: Documentation", + "Topic :: Software Development :: Documentation", +] +dynamic = ["version"] +requires-python = ">=3.8" +dependencies = ["jinja2", "markdown", "mkdocs"] + +[project.urls] +Documentation = "https://github.com/daizutabi/mkapi#readme" # FIXME +Source = "https://github.com/daizutabi/mkapi" +Issues = "https://github.com/daizutabi/mkapi/issues" + +[project.scripts] +mkapi = "mkapi.main:cli" + +[project.entry-points."mkdocs.plugins"] +mkapi = "mkapi.plugins.mkdocs:MkapiPlugin" + +[tool.hatch.version] +path = "src/mkapi/__about__.py" + +[tool.hatch.build.targets.sdist] +exclude = ["/.github", "/docs"] +[tool.hatch.build.targets.wheel] +packages = ["src/mkapi"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.default] +dependencies = ["pytest-cov"] +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests src/mkapi}" + +[tool.pytest.ini_options] +addopts = [ + "--verbose", + "--color=yes", + "--doctest-modules", + "--cov=mkapi", + "--cov-report=lcov:lcov.info", + # "--cov-report=term:skip-covered", +] +doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] +# testpaths = ["tests", "src/mkapi"] + +[tool.coverage.run] +omit = ["src/mkapi/__about__.py"] + +[tool.coverage.report] +exclude_lines = ["no cov"] + +[tool.hatch.envs.docs] +dependencies = ["mkdocs", "mkdocs-material"] +[tool.hatch.envs.docs.scripts] +build = "mkdocs build --clean --strict {args}" +serve = "mkdocs serve --dev-addr localhost:8000 {args}" +deploy = "mkdocs gh-deploy --force" + +[tool.ruff] +target-version = "py311" +line-length = 88 +select = ["ALL"] +ignore = ["ANN101", "ANN102", "ANN002", "ANN003"] + +[tool.ruff.extend-per-file-ignores] +"tests/*.py" = ["ANN", "D", "S101", "INP001", "PLR2004"] + +[tool.ruff.isort] +known-first-party = ["mkapi"] + +[tool.ruff.lint] +unfixable = [ + "RUF100", # Don't touch noqa lines +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7faa838c..00000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -mkdocs >= 1.1.2, < 2 -mkdocs-ivory >= 0.4.6, < 1 -pheasant >= 2.5.9, < 3 -pymdown-extensions >= 7.1, < 8 -pytest >= 5.4.3, < 6 -pytest-cov >= 2.10, < 3 --e . # mkapi as development lib \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5599627b..00000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[aliases] -test=pytest - -[tool:pytest] -addopts = --verbose --doctest-modules --cov=mkapi - --cov-report=html --color=yes --exitfirst -testpaths = tests mkapi -python_files = test*.py - -[mypy] -ignore_missing_imports = True - -[pycodestyle] -max-line-length = 88 -ignore = E203, E123, W503, E402 - -[flake8] -max-line-length = 88 -ignore = E203, E123, W503, E402 diff --git a/setup.py b/setup.py deleted file mode 100644 index 4d7ece4f..00000000 --- a/setup.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -import re -import subprocess -import sys - -from setuptools import setup - - -def get_version(package: str) -> str: - """Return version of the package.""" - path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), package, "__init__.py" - ) - with open(path, "r") as file: - source = file.read() - m = re.search(r'__version__ = ["\'](.+)["\']', source) - if m: - return m.group(1) - else: - return "0.0.0" - - -def get_packages(package): - """Return root package and all sub-packages.""" - return [ - dirpath - for dirpath, dirnames, filenames in os.walk(package) - if os.path.exists(os.path.join(dirpath, "__init__.py")) - ] - - -long_description = "" - - -def check(): - def run(command): - assert subprocess.run(command.split()).returncode == 0 - print(f"'{command}' --- OK") - - run("pycodestyle mkapi") - run("pyflakes mkapi") - run("mypy mkapi") - run("pycodestyle tests") - run("pyflakes tests") - - -def publish(): - check() - subprocess.run("python setup.py sdist bdist_wheel".split()) - subprocess.run("twine upload dist/*".split()) - version = get_version("mkapi") - subprocess.run(["git", "tag", "-a", f"v{version}", "-m", f"'Version {version}'"]) - subprocess.run(["git", "push", "origin", "--tags"]) - sys.exit(0) - - -if sys.argv[-1] == "publish": - publish() - -if sys.argv[-1] == "check": - check() - - -setup( - name="mkapi", - version=get_version("mkapi"), - description="An Auto API Documentation tool.", - long_description=long_description, - url="https://mkapi.daizutabi.net", - author="daizutabi", - author_email="daizutabi@gmail.com", - license="MIT", - packages=get_packages("mkapi") + ["mkapi/templates", "mkapi/theme"], # FIXME - include_package_data=True, - install_requires=["markdown", "jinja2"], - python_requires=">=3.7", - entry_points={ - "console_scripts": ["mkapi = mkapi.main:cli"], - "mkdocs.plugins": ["mkapi = mkapi.plugins.mkdocs:MkapiPlugin"], - }, - classifiers=[ - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Topic :: Documentation", - ], -) diff --git a/src/mkapi/__about__.py b/src/mkapi/__about__.py new file mode 100644 index 00000000..eee00e3d --- /dev/null +++ b/src/mkapi/__about__.py @@ -0,0 +1 @@ +__version__ = "1.1.0" diff --git a/mkapi/__init__.py b/src/mkapi/__init__.py similarity index 100% rename from mkapi/__init__.py rename to src/mkapi/__init__.py diff --git a/mkapi/core/__init__.py b/src/mkapi/core/__init__.py similarity index 100% rename from mkapi/core/__init__.py rename to src/mkapi/core/__init__.py diff --git a/mkapi/core/attribute.py b/src/mkapi/core/attribute.py similarity index 100% rename from mkapi/core/attribute.py rename to src/mkapi/core/attribute.py diff --git a/mkapi/core/base.py b/src/mkapi/core/base.py similarity index 100% rename from mkapi/core/base.py rename to src/mkapi/core/base.py diff --git a/mkapi/core/code.py b/src/mkapi/core/code.py similarity index 100% rename from mkapi/core/code.py rename to src/mkapi/core/code.py diff --git a/mkapi/core/docstring.py b/src/mkapi/core/docstring.py similarity index 100% rename from mkapi/core/docstring.py rename to src/mkapi/core/docstring.py diff --git a/mkapi/core/inherit.py b/src/mkapi/core/inherit.py similarity index 100% rename from mkapi/core/inherit.py rename to src/mkapi/core/inherit.py diff --git a/mkapi/core/linker.py b/src/mkapi/core/linker.py similarity index 100% rename from mkapi/core/linker.py rename to src/mkapi/core/linker.py diff --git a/mkapi/core/module.py b/src/mkapi/core/module.py similarity index 100% rename from mkapi/core/module.py rename to src/mkapi/core/module.py diff --git a/mkapi/core/node.py b/src/mkapi/core/node.py similarity index 100% rename from mkapi/core/node.py rename to src/mkapi/core/node.py diff --git a/mkapi/core/object.py b/src/mkapi/core/object.py similarity index 100% rename from mkapi/core/object.py rename to src/mkapi/core/object.py diff --git a/mkapi/core/page.py b/src/mkapi/core/page.py similarity index 100% rename from mkapi/core/page.py rename to src/mkapi/core/page.py diff --git a/mkapi/core/postprocess.py b/src/mkapi/core/postprocess.py similarity index 100% rename from mkapi/core/postprocess.py rename to src/mkapi/core/postprocess.py diff --git a/mkapi/core/preprocess.py b/src/mkapi/core/preprocess.py similarity index 100% rename from mkapi/core/preprocess.py rename to src/mkapi/core/preprocess.py diff --git a/mkapi/core/regex.py b/src/mkapi/core/regex.py similarity index 100% rename from mkapi/core/regex.py rename to src/mkapi/core/regex.py diff --git a/mkapi/core/renderer.py b/src/mkapi/core/renderer.py similarity index 100% rename from mkapi/core/renderer.py rename to src/mkapi/core/renderer.py diff --git a/mkapi/core/signature.py b/src/mkapi/core/signature.py similarity index 89% rename from mkapi/core/signature.py rename to src/mkapi/core/signature.py index 03d499d9..ef5c29bc 100644 --- a/mkapi/core/signature.py +++ b/src/mkapi/core/signature.py @@ -1,10 +1,9 @@ -"""This module provides Signature class that inspects object and creates -signature and types.""" +"""Signature class that inspects object and creates signature and types.""" import importlib import inspect from dataclasses import InitVar, dataclass, field, is_dataclass from functools import lru_cache -from typing import Any, Dict, List, Optional, TypeVar, Union +from typing import Any, TypeVar, Union from mkapi.core import linker, preprocess from mkapi.core.attribute import get_attributes @@ -16,9 +15,11 @@ class Signature: """Signature class. Args: + ---- obj: Object Attributes: + ---------- signature: `inspect.Signature` instance. parameters: Parameters section. defaults: Default value dictionary. Key is parameter name and @@ -29,9 +30,9 @@ class Signature: """ obj: Any = field(default=None, repr=False) - signature: Optional[inspect.Signature] = field(default=None, init=False) + signature: inspect.Signature | None = field(default=None, init=False) parameters: Section = field(default_factory=Section, init=False) - defaults: Dict[str, Any] = field(default_factory=dict, init=False) + defaults: dict[str, Any] = field(default_factory=dict, init=False) attributes: Section = field(default_factory=Section, init=False) returns: str = field(default="", init=False) yields: str = field(default="", init=False) @@ -77,7 +78,7 @@ def __post_init__(self): self.returns = to_string(return_annotation, "returns", obj=self.obj) self.yields = to_string(return_annotation, "yields", obj=self.obj) - def __contains__(self, name): + def __contains__(self, name) -> bool: return name in self.parameters def __getitem__(self, name): @@ -91,7 +92,7 @@ def __str__(self): return "(" + ", ".join(args) + ")" @property - def arguments(self) -> Optional[List[str]]: + def arguments(self) -> list[str] | None: """Returns arguments list.""" if self.obj is None or not callable(self.obj): return None @@ -105,14 +106,13 @@ def arguments(self) -> Optional[List[str]]: return args def set_attributes(self): - """ - Examples: - >>> from mkapi.core.base import Base - >>> s = Signature(Base) - >>> s.parameters['name'].to_tuple() - ('name', 'str, optional', 'Name of self.') - >>> s.attributes['html'].to_tuple() - ('html', 'str', 'HTML output after conversion.') + """Examples + >>> from mkapi.core.base import Base + >>> s = Signature(Base) + >>> s.parameters['name'].to_tuple() + ('name', 'str, optional', 'Name of self.') + >>> s.attributes['html'].to_tuple() + ('html', 'str', 'HTML output after conversion.') """ items = [] for name, (type, description) in get_attributes(self.obj).items(): @@ -138,15 +138,17 @@ def split(self, sep=","): def to_string(annotation, kind: str = "returns", obj=None) -> str: - """Returns string expression of annotation. + """Return string expression of annotation. If possible, type string includes link. Args: + ---- annotation: Annotation kind: 'returns' or 'yields' Examples: + -------- >>> from typing import Callable, Iterator, List >>> to_string(Iterator[str]) 'iterator of str' @@ -164,10 +166,8 @@ def to_string(annotation, kind: str = "returns", obj=None) -> str: if hasattr(annotation, "__args__") and annotation.__args__: if len(annotation.__args__) == 1: return to_string(annotation.__args__[0], obj=obj) - else: - return to_string(annotation, obj=obj) - else: - return "" + return to_string(annotation, obj=obj) + return "" if annotation == ...: return "..." @@ -208,12 +208,14 @@ def to_string(annotation, kind: str = "returns", obj=None) -> str: def a_of_b(annotation, obj=None) -> str: - """Returns A of B style string. + """Return "A of B" style string. Args: + ---- annotation: Annotation Examples: + -------- >>> from typing import List, Iterable, Iterator >>> a_of_b(List[str]) 'list of str' @@ -237,12 +239,14 @@ def a_of_b(annotation, obj=None) -> str: def union(annotation, obj=None) -> str: - """Returns a string for union annotation. + """Return a string for union annotation. Args: + ---- annotation: Annotation Examples: + -------- >>> from typing import List, Optional, Tuple, Union >>> union(Optional[List[str]]) 'list of str, optional' @@ -272,12 +276,14 @@ def union(annotation, obj=None) -> str: def to_string_args(annotation, obj=None) -> str: - """Returns a string for callable and generator annotation. + """Return a string for callable and generator annotation. Args: + ---- annotation: Annotation Examples: + -------- >>> from typing import Callable, List, Tuple, Any >>> from typing import Generator, AsyncGenerator >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) @@ -294,12 +300,11 @@ def to_string_args(annotation, obj=None) -> str: 'asyncgenerator(int, float)' """ - def to_string_with_prefix(annotation, prefix=","): + def to_string_with_prefix(annotation, prefix: str = ",") -> str: s = to_string(annotation, obj=obj) if s in ["NoneType", "any"]: return "" - else: - return " ".join([prefix, s]) + return f"{prefix} {s}" args = annotation.__args__ name = annotation.__origin__.__name__.lower() @@ -308,7 +313,7 @@ def to_string_with_prefix(annotation, prefix=","): args = ", ".join(to_string(x, obj=obj) for x in args) returns = to_string_with_prefix(returns, ":") return f"{name}({args}{returns})" - elif name == "generator": + if name == "generator": arg, sends, returns = args arg = to_string(arg, obj=obj) sends = to_string_with_prefix(sends) @@ -316,23 +321,24 @@ def to_string_with_prefix(annotation, prefix=","): if not sends and returns: sends = "," return f"{name}({arg}{sends}{returns})" - elif name == "asyncgenerator": + if name == "asyncgenerator": arg, sends = args arg = to_string(arg, obj=obj) sends = to_string_with_prefix(sends) return f"{name}({arg}{sends})" - else: - return "" + return "" def resolve_forward_ref(obj: Any, name: str) -> str: - """Returns a resolved name for `typing.ForwardRef`. + """Return a resolved name for `typing.ForwardRef`. Args: + ---- obj: Object name: Forward reference name. Examples: + -------- >>> from mkapi.core.base import Docstring >>> resolve_forward_ref(Docstring, 'Docstring') '[Docstring](!mkapi.core.base.Docstring)' diff --git a/mkapi/core/structure.py b/src/mkapi/core/structure.py similarity index 100% rename from mkapi/core/structure.py rename to src/mkapi/core/structure.py diff --git a/mkapi/main.py b/src/mkapi/main.py similarity index 100% rename from mkapi/main.py rename to src/mkapi/main.py diff --git a/mkapi/plugins/__init__.py b/src/mkapi/plugins/__init__.py similarity index 100% rename from mkapi/plugins/__init__.py rename to src/mkapi/plugins/__init__.py diff --git a/mkapi/plugins/api.py b/src/mkapi/plugins/api.py similarity index 100% rename from mkapi/plugins/api.py rename to src/mkapi/plugins/api.py diff --git a/mkapi/plugins/mkdocs.py b/src/mkapi/plugins/mkdocs.py similarity index 100% rename from mkapi/plugins/mkdocs.py rename to src/mkapi/plugins/mkdocs.py diff --git a/mkapi/templates/bases.jinja2 b/src/mkapi/templates/bases.jinja2 similarity index 100% rename from mkapi/templates/bases.jinja2 rename to src/mkapi/templates/bases.jinja2 diff --git a/mkapi/templates/code.jinja2 b/src/mkapi/templates/code.jinja2 similarity index 100% rename from mkapi/templates/code.jinja2 rename to src/mkapi/templates/code.jinja2 diff --git a/mkapi/templates/docstring.jinja2 b/src/mkapi/templates/docstring.jinja2 similarity index 100% rename from mkapi/templates/docstring.jinja2 rename to src/mkapi/templates/docstring.jinja2 diff --git a/mkapi/templates/items.jinja2 b/src/mkapi/templates/items.jinja2 similarity index 100% rename from mkapi/templates/items.jinja2 rename to src/mkapi/templates/items.jinja2 diff --git a/mkapi/templates/macros.jinja2 b/src/mkapi/templates/macros.jinja2 similarity index 100% rename from mkapi/templates/macros.jinja2 rename to src/mkapi/templates/macros.jinja2 diff --git a/mkapi/templates/member.jinja2 b/src/mkapi/templates/member.jinja2 similarity index 100% rename from mkapi/templates/member.jinja2 rename to src/mkapi/templates/member.jinja2 diff --git a/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 similarity index 100% rename from mkapi/templates/module.jinja2 rename to src/mkapi/templates/module.jinja2 diff --git a/mkapi/templates/node.jinja2 b/src/mkapi/templates/node.jinja2 similarity index 100% rename from mkapi/templates/node.jinja2 rename to src/mkapi/templates/node.jinja2 diff --git a/mkapi/templates/object.jinja2 b/src/mkapi/templates/object.jinja2 similarity index 100% rename from mkapi/templates/object.jinja2 rename to src/mkapi/templates/object.jinja2 diff --git a/mkapi/theme/css/mkapi-common.css b/src/mkapi/theme/css/mkapi-common.css similarity index 100% rename from mkapi/theme/css/mkapi-common.css rename to src/mkapi/theme/css/mkapi-common.css diff --git a/mkapi/theme/css/mkapi-ivory.css b/src/mkapi/theme/css/mkapi-ivory.css similarity index 100% rename from mkapi/theme/css/mkapi-ivory.css rename to src/mkapi/theme/css/mkapi-ivory.css diff --git a/mkapi/theme/css/mkapi-mkdocs.css b/src/mkapi/theme/css/mkapi-mkdocs.css similarity index 100% rename from mkapi/theme/css/mkapi-mkdocs.css rename to src/mkapi/theme/css/mkapi-mkdocs.css diff --git a/mkapi/theme/css/mkapi-readthedocs.css b/src/mkapi/theme/css/mkapi-readthedocs.css similarity index 100% rename from mkapi/theme/css/mkapi-readthedocs.css rename to src/mkapi/theme/css/mkapi-readthedocs.css diff --git a/mkapi/theme/js/mkapi.js b/src/mkapi/theme/js/mkapi.js similarity index 100% rename from mkapi/theme/js/mkapi.js rename to src/mkapi/theme/js/mkapi.js diff --git a/mkapi/theme/mkapi.yml b/src/mkapi/theme/mkapi.yml similarity index 100% rename from mkapi/theme/mkapi.yml rename to src/mkapi/theme/mkapi.yml diff --git a/mkapi/utils.py b/src/mkapi/utils.py similarity index 96% rename from mkapi/utils.py rename to src/mkapi/utils.py index 2e540013..c7338b57 100644 --- a/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -1,5 +1,5 @@ import importlib -from typing import Any, List +from typing import Any def get_indent(line: str) -> int: @@ -71,7 +71,7 @@ def split_filters(name): return name, filters.split("|") -def update_filters(org: List[str], update: List[str]) -> List[str]: +def update_filters(org: list[str], update: list[str]) -> list[str]: """ Examples: >>> update_filters(['upper'], ['lower']) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/test_core_module.py b/tests/core/test_core_module.py index 3d2c85f6..c2be67ea 100644 --- a/tests/core/test_core_module.py +++ b/tests/core/test_core_module.py @@ -1,4 +1,4 @@ -from mkapi.core.module import get_members, get_module, modules +from mkapi.core.module import get_module, modules def test_get_module(): @@ -24,12 +24,6 @@ def test_repr(): assert repr(module) == s -def test_get_members(): - from mkapi import theme - - assert get_members(theme) == [] - - def test_get_module_from_object(): from mkapi.core import base From c19fd84ff4e0865da63caa6033f61c1cd273071a Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 24 Dec 2023 20:00:28 +0900 Subject: [PATCH 002/148] linker.py --- .vscode/settings.json | 4 +- README.md | 10 +- docs/appendix/inherit.md | 2 +- docs/examples/google_style.md | 6 +- docs/examples/numpy_style.md | 6 +- docs/index.md | 12 +- docs/usage/custom.md | 8 +- docs/usage/inherit.md | 2 +- docs/usage/library.py | 12 +- docs/usage/module.md | 4 +- docs/usage/page.md | 8 +- mkdocs.yml | 2 +- pyproject.toml | 23 +- src/mkapi/__about__.py | 2 + src/mkapi/core/base.py | 59 ++- src/mkapi/core/docstring.py | 28 +- src/mkapi/core/linker.py | 100 ++--- src/mkapi/core/object.py | 20 +- src/mkapi/core/page.py | 42 +- src/mkapi/core/regex.py | 19 - src/mkapi/core/signature.py | 362 ------------------ src/mkapi/core/structure.py | 36 +- src/mkapi/examples/__init__.py | 1 + .../mkapi/examples/cls}/__init__.py | 0 .../mkapi/examples/cls}/abc.py | 9 +- .../mkapi/examples/cls}/decorated.py | 10 +- .../mkapi/examples/cls}/decorator.py | 0 .../mkapi/examples/cls}/inherit.py | 0 .../mkapi/examples/cls}/member_order_base.py | 0 .../mkapi/examples/cls}/member_order_sub.py | 2 +- .../mkapi/examples/cls}/method.py | 15 +- {examples => src/mkapi/examples}/custom.py | 0 {examples => src/mkapi/examples}/filter.py | 3 +- {examples => src/mkapi/examples}/inherit.py | 8 +- .../mkapi/examples}/inherit_comment.py | 6 +- src/mkapi/examples/inspect.py | 12 + .../mkapi/examples/link}/__init__.py | 0 .../mkapi/examples}/link/fullname.py | 0 .../mkapi/examples}/link/qualname.py | 0 {examples => src/mkapi/examples}/meta.py | 2 +- src/mkapi/examples/styles/__init__.py | 0 .../mkapi/examples/styles/google.py | 16 +- .../mkapi/examples/styles/numpy.py | 16 +- src/mkapi/inspect/__init__.py | 1 + src/mkapi/inspect/annotation.py | 231 +++++++++++ src/mkapi/inspect/signature.py | 175 +++++++++ src/mkapi/plugins/api.py | 4 +- src/mkapi/plugins/mkdocs.py | 4 +- tests/conftest.py | 10 +- tests/core/test_core_attribute.py | 38 +- tests/core/test_core_node.py | 4 +- tests/core/test_core_signature.py | 55 --- tests/core/test_mro.py | 6 +- tests/inspect/test_inspect_annotation.py | 12 + tests/inspect/test_inspect_signature.py | 89 +++++ tests/library/test_types.py | 7 + 56 files changed, 849 insertions(+), 654 deletions(-) delete mode 100644 src/mkapi/core/regex.py delete mode 100644 src/mkapi/core/signature.py create mode 100644 src/mkapi/examples/__init__.py rename {examples => src/mkapi/examples/cls}/__init__.py (100%) rename {examples/appendix => src/mkapi/examples/cls}/abc.py (87%) rename {examples/appendix => src/mkapi/examples/cls}/decorated.py (75%) rename {examples/appendix => src/mkapi/examples/cls}/decorator.py (100%) rename {examples/appendix => src/mkapi/examples/cls}/inherit.py (100%) rename {examples/appendix => src/mkapi/examples/cls}/member_order_base.py (100%) rename {examples/appendix => src/mkapi/examples/cls}/member_order_sub.py (79%) rename {examples/appendix => src/mkapi/examples/cls}/method.py (63%) rename {examples => src/mkapi/examples}/custom.py (100%) rename {examples => src/mkapi/examples}/filter.py (72%) rename {examples => src/mkapi/examples}/inherit.py (83%) rename {examples => src/mkapi/examples}/inherit_comment.py (80%) create mode 100644 src/mkapi/examples/inspect.py rename {examples/appendix => src/mkapi/examples/link}/__init__.py (100%) rename {examples => src/mkapi/examples}/link/fullname.py (100%) rename {examples => src/mkapi/examples}/link/qualname.py (100%) rename {examples => src/mkapi/examples}/meta.py (94%) create mode 100644 src/mkapi/examples/styles/__init__.py rename examples/google_style.py => src/mkapi/examples/styles/google.py (88%) rename examples/numpy_style.py => src/mkapi/examples/styles/numpy.py (86%) create mode 100644 src/mkapi/inspect/__init__.py create mode 100644 src/mkapi/inspect/annotation.py create mode 100644 src/mkapi/inspect/signature.py delete mode 100644 tests/core/test_core_signature.py create mode 100644 tests/inspect/test_inspect_annotation.py create mode 100644 tests/inspect/test_inspect_signature.py create mode 100644 tests/library/test_types.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 58b50288..7fe04efe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { + "python.testing.autoTestDiscoverOnSaveEnabled": true, "python.testing.pytestEnabled": true, - "python.testing.unittestEnabled": false + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": ["tests", "src/mkapi"] } diff --git a/README.md b/README.md index 0a8d684a..1e90411b 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ [![Python versions][pyversions-image]][pyversions-link] [![Code style: black][black-image]][black-link] -MkApi plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkApi supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). +MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkAPI supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). -Features of MkApi are: +Features of MkAPI are: * **Type annotation**: If you write your function such as `def func(x: int) -> str:`, you don't need write type(s) in Parameters, Returns, or Yields section again. You can overwrite the type annotation in the corresponding docstring. -* **Object type inspection**: MkApi plugin creates *class*, *dataclass*, *function*, or *generator* prefix for each object. +* **Object type inspection**: MkAPI plugin creates *class*, *dataclass*, *function*, or *generator* prefix for each object. * **Attribute inspection**: If you write attributes with description as comment in module or class, Attributes section is automatically created. * **Docstring inheritance**: Docstring of a subclass can inherit parameters and attributes description from its superclasses. * **Table of Contents**: Table of contents are inserted into the documentation of each package, module, and class. @@ -36,7 +36,7 @@ plugins: ## Usage -MkApi provides two modes to generate API documentation: Embedding mode and Page mode. +MkAPI provides two modes to generate API documentation: Embedding mode and Page mode. ### Embedding Mode @@ -52,7 +52,7 @@ You can combine this syntax with Markdown heading: ## ![mkapi]() ~~~ -MkApi imports objects that you specify. If they aren't in the `sys.path`, configure `mkdocs.yml` like below: +MkAPI imports objects that you specify. If they aren't in the `sys.path`, configure `mkdocs.yml` like below: ~~~yml plugins: diff --git a/docs/appendix/inherit.md b/docs/appendix/inherit.md index 9bc59459..47d2391f 100644 --- a/docs/appendix/inherit.md +++ b/docs/appendix/inherit.md @@ -48,7 +48,7 @@ Base.func.__doc__, inspect.getdoc(Base.func) Sub.func.__doc__, inspect.getdoc(Sub.func) ``` -Because `Sub.func()` has no docstring, its `__doc__` attribute is `None`. On the other hand, the super class `Base.func()` has docstring, so that you can get the *inherited* docstring using `inspect.getdoc()`. Therefore, MkApi uses `inspect.getdoc()`. +Because `Sub.func()` has no docstring, its `__doc__` attribute is `None`. On the other hand, the super class `Base.func()` has docstring, so that you can get the *inherited* docstring using `inspect.getdoc()`. Therefore, MkAPI uses `inspect.getdoc()`. Now, let's see some special methods: diff --git a/docs/examples/google_style.md b/docs/examples/google_style.md index 5723f394..f6e820b0 100644 --- a/docs/examples/google_style.md +++ b/docs/examples/google_style.md @@ -45,14 +45,14 @@ Then, write a *tag* anywhere in your Markdown source: ![mkapi](google_style.add) ~~~ -MkApi generates the documentation for the `add()` function. +MkAPI generates the documentation for the `add()` function. ![mkapi](google_style.add) !!! note - In the above example, green dashed border lines are just guide for the eye to show the region of the documentation generated by MkApi for convenience. + In the above example, green dashed border lines are just guide for the eye to show the region of the documentation generated by MkAPI for convenience. -In this simple example, you can see some features of MkApi. +In this simple example, you can see some features of MkAPI. * Use of type annotation for both Parameters and Returns sections. * Add "optional" if parameters have default values. diff --git a/docs/examples/numpy_style.md b/docs/examples/numpy_style.md index 25607f53..3f2d4ee9 100644 --- a/docs/examples/numpy_style.md +++ b/docs/examples/numpy_style.md @@ -46,14 +46,14 @@ Then, write a *tag* anywhere in your Markdown source: ![mkapi](numpy_style.add) ~~~ -MkApi generates the API documentation for the `add()` function. +MkAPI generates the API documentation for the `add()` function. ![mkapi](numpy_style.add) !!! note - In the above example, green dashed border lines are just guide for the eye to show the region of the documentation generated by MkApi for convenience. + In the above example, green dashed border lines are just guide for the eye to show the region of the documentation generated by MkAPI for convenience. -In this simple example, you can see some features of MkApi. +In this simple example, you can see some features of MkAPI. * Use of type annotation for both Parameters and Returns sections. * Add *optional* if parameters have default values. diff --git a/docs/index.md b/docs/index.md index d9cdd022..96de1aad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,11 @@ -# MkApi Documentation +# MkAPI Documentation -MkApi plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkApi supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). +MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkAPI supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). -Features of MkApi are: +Features of MkAPI are: * **Type annotation**: If you write your function such as `def func(x: int) -> str:`, you don't need write type(s) in Parameters, Returns, or Yields section again. You can overwrite the type annotation in the corresponding docstring. -* **Object type inspection**: MkApi plugin creates *class*, *dataclass*, *function*, or *generator* prefix for each object. +* **Object type inspection**: MkAPI plugin creates *class*, *dataclass*, *function*, or *generator* prefix for each object. * **Attribute inspection**: If you write attributes with description as comment in module or class, Attributes section is automatically created. * **Docstring inheritance**: Docstring of a subclass can inherit parameters and attributes description from its superclasses. * **Table of Contents**: Table of contents are inserted into the documentation of each package, module, and class. @@ -32,7 +32,7 @@ plugins: ## Usage -MkApi provides two modes to generate documentation: Embedding mode and Page mode. +MkAPI provides two modes to generate documentation: Embedding mode and Page mode. ### Embedding Mode @@ -48,7 +48,7 @@ You can combine this syntax with Markdown heading: ## ![mkapi]() ~~~ -MkApi imports modules that you specify. If they aren't in the `sys.path`, configure `mkdocs.yml` like below: +MkAPI imports modules that you specify. If they aren't in the `sys.path`, configure `mkdocs.yml` like below: ~~~yml plugins: diff --git a/docs/usage/custom.md b/docs/usage/custom.md index 83b4a3f9..6c58476e 100644 --- a/docs/usage/custom.md +++ b/docs/usage/custom.md @@ -2,7 +2,7 @@ ## Customization 'on_config'. -MkApi has an option `on_config` to allow users to configure MkDocs/MkApi or +MkAPI has an option `on_config` to allow users to configure MkDocs/MkAPI or user system environment. Here is an example directory structure and the corresponding `mkdocs.yml`: ~~~yml @@ -32,7 +32,7 @@ Let's build the documentation. ~~~bash $ mkdocs build -INFO - [MkApi] Calling user 'on_config' with [] +INFO - [MkAPI] Calling user 'on_config' with [] Called. INFO - Cleaning site directory ... @@ -51,7 +51,7 @@ plugins: ~~~bash $ mkdocs build -INFO - [MkApi] Calling user 'on_config' with ['config'] +INFO - [MkAPI] Calling user 'on_config' with ['config'] Called with config. C:\Users\daizu\Documents\github\mkapi\docs INFO - Cleaning site directory @@ -69,7 +69,7 @@ plugins: ~~~bash $ mkdocs build -INFO - [MkApi] Calling user 'on_config' with ['config', 'mkapi'] +INFO - [MkAPI] Calling user 'on_config' with ['config', 'mkapi'] Called with config and mkapi. C:\Users\daizu\Documents\github\mkapi\docs diff --git a/docs/usage/inherit.md b/docs/usage/inherit.md index 3a2a9857..18c371e5 100644 --- a/docs/usage/inherit.md +++ b/docs/usage/inherit.md @@ -43,7 +43,7 @@ By inheritance from superclasses, you don't need to write duplicated description ## Inheritance from Signature -Using `strict` filter, MkApi also adds parameters and attributes without description using its signature. Description is still empty but type is inspected. +Using `strict` filter, MkAPI also adds parameters and attributes without description using its signature. Description is still empty but type is inspected. ~~~markdown ![mkapi](inherit.Item|strict) diff --git a/docs/usage/library.py b/docs/usage/library.py index 5858ae5c..5161e5ea 100644 --- a/docs/usage/library.py +++ b/docs/usage/library.py @@ -1,10 +1,10 @@ """md -# Using MkApi within Python +# Using MkAPI within Python -MkApi is a standalone library as well as a MkDocs plugin, so that you can use it +MkAPI is a standalone library as well as a MkDocs plugin, so that you can use it within Python. -First, import MkApi: +First, import MkAPI: {{ # cache:clear }} @@ -19,7 +19,7 @@ # ## Node object -# Define a simple class to show how MkApi works. +# Define a simple class to show how MkAPI works. class A: @@ -99,7 +99,7 @@ def to_str(self, x: int) -> str: # `Node.get_markdown()` divides docstrings into two parts. One is a plain Markdown that # will be converted into HTML by any Markdown converter, for example, MkDocs. The other # is the outline structure of docstrings such as sections or arguments that will be -# processed by MkApi itself. +# processed by MkAPI itself. # ## Converting Markdown @@ -108,7 +108,7 @@ def to_str(self, x: int) -> str: from markdown import Markdown # isort:skip -converter = Markdown(extensions=['admonition']) +converter = Markdown(extensions=["admonition"]) html = converter.convert(markdown) print(html) diff --git a/docs/usage/module.md b/docs/usage/module.md index ddaffc3b..d2186b02 100644 --- a/docs/usage/module.md +++ b/docs/usage/module.md @@ -10,7 +10,7 @@ {{ # cache:clear }} -MkApi can create module and package documentation as well as function and class. Specify a package or module by its full path name. +MkAPI can create module and package documentation as well as function and class. Specify a package or module by its full path name. ~~~ ![mkapi](!!mkapi.core) @@ -25,7 +25,7 @@ As class level attributes, module level attributes can be inspected. Here is the #File google_style.py: line number 1-18 {%=/examples/google_style.py[:18]%} -Although there is no Attributes section in docstring, MkApi automatically creates the section if attributes are correctly documented. +Although there is no Attributes section in docstring, MkAPI automatically creates the section if attributes are correctly documented. ~~~ ![mkapi](google_style) diff --git a/docs/usage/page.md b/docs/usage/page.md index 2c234cb9..ec77fa60 100644 --- a/docs/usage/page.md +++ b/docs/usage/page.md @@ -20,7 +20,7 @@ nav: - API: mkapi/api/mkapi ~~~ -MkApi scans the `nav` to find an entry that starts with `'mkapi/'`. This entry must include two or more slashes (`'/'`). Second part (`'api'`) splitted by slash is a directory name. MkApi automatically creates this directory in the `docs` directory at the beginning of the process and deletes it and its contents after the process. +MkAPI scans the `nav` to find an entry that starts with `'mkapi/'`. This entry must include two or more slashes (`'/'`). Second part (`'api'`) splitted by slash is a directory name. MkAPI automatically creates this directory in the `docs` directory at the beginning of the process and deletes it and its contents after the process. The rest (`'mkapi'`) is a root package name, which is assumed to exist in the `mkdocs.yml` directory. However, if a root package is in `src` directory, for example, you can specify it like this: @@ -29,10 +29,10 @@ The rest (`'mkapi'`) is a root package name, which is assumed to exist in the `m ~~~ -MkApi searches all packages and modules and create a Markdown source for one package or module, which is saved in the `api` directory. The rest work is done by MkDocs. You can see the documentation of MkApi in the left navigation menu. +MkAPI searches all packages and modules and create a Markdown source for one package or module, which is saved in the `api` directory. The rest work is done by MkDocs. You can see the documentation of MkAPI in the left navigation menu. !!! note - * If a package or module has no package- or module-level docstring and its members have no docstring as well, MkApi doesn't process it. + * If a package or module has no package- or module-level docstring and its members have no docstring as well, MkAPI doesn't process it. * For upper case heading, use the `upper` filter. See [Documentation with Heading](../module/#documentation-with-heading). ## Internal Links @@ -110,7 +110,7 @@ You can click the prefix (`mkapi.core.docstring`) or the function name (`section ### Link from Type -[Docstring](mkapi.core.base.Docstring) class of MkApi has an attribute `sections` that is a list of `Section` class instance: +[Docstring](mkapi.core.base.Docstring) class of MkAPI has an attribute `sections` that is a list of `Section` class instance: ~~~python # Mimic code of Docstring class. diff --git a/mkdocs.yml b/mkdocs.yml index 46995273..2e020081 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: MkApi +site_name: MkAPI site_url: https://mkapi.daizutabi.net/ site_description: API documentation with MkDocs. site_author: daizutabi diff --git a/pyproject.toml b/pyproject.toml index 84673752..0de665eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,15 +76,28 @@ deploy = "mkdocs gh-deploy --force" target-version = "py311" line-length = 88 select = ["ALL"] -ignore = ["ANN101", "ANN102", "ANN002", "ANN003"] +ignore = ["ANN101", "ANN102", "ANN002", "ANN003", "ERA001", "N812"] [tool.ruff.extend-per-file-ignores] -"tests/*.py" = ["ANN", "D", "S101", "INP001", "PLR2004"] - -[tool.ruff.isort] -known-first-party = ["mkapi"] +"tests/*.py" = ["ANN", "D", "S101", "INP001", "T201", "PLR2004", "PGH003"] +"src/mkapi/examples/*.py" = [ + "ANN", + "D", + "ERA001", + "INP001", + "PLR2004", + "S101", + "T201", + "ARG001", + "PLR0913", + "E741", +] [tool.ruff.lint] unfixable = [ "RUF100", # Don't touch noqa lines + "F401", # Don't touch unused imports ] + +[tool.ruff.isort] +known-first-party = ["mkapi"] diff --git a/src/mkapi/__about__.py b/src/mkapi/__about__.py index eee00e3d..d4d4a279 100644 --- a/src/mkapi/__about__.py +++ b/src/mkapi/__about__.py @@ -1 +1,3 @@ +"""Dynamic version.""" + __version__ = "1.1.0" diff --git a/src/mkapi/core/base.py b/src/mkapi/core/base.py index 61edfe98..4ef97796 100644 --- a/src/mkapi/core/base.py +++ b/src/mkapi/core/base.py @@ -1,16 +1,18 @@ -"""This module provides entity classes to represent docstring structure.""" +"""A module provides entity classes to represent docstring structure.""" +from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from typing import Callable, Iterator, List, Optional, Tuple +from typing import Optional from mkapi.core import preprocess -from mkapi.core.regex import LINK_PATTERN +from mkapi.core.linker import LINK_PATTERN @dataclass class Base: """Base class. - Examples: + Examples + -------- >>> base = Base('x', 'markdown') >>> base Base('x') @@ -48,6 +50,7 @@ def set_html(self, html: str): """Sets HTML output. Args: + ---- html: HTML output. """ self.html = html @@ -63,7 +66,8 @@ def copy(self): class Inline(Base): """Inline class. - Examples: + Examples + -------- >>> inline = Inline() >>> bool(inline) False @@ -102,7 +106,8 @@ class Type(Inline): """Type class represents type of Item_, Section_, Docstring_, or [Object](mkapi.core.structure.Object). - Examples: + Examples + -------- >>> a = Type('str') >>> a Type('str') @@ -132,12 +137,14 @@ class Item(Type): *etc.* Args: + ---- type: Type of self. description: Description of self. kind: Kind of self, for example `readonly property`. This value is rendered as a class attribute in HTML. Examples: + -------- >>> item = Item('[x](x)', Type('int'), Inline('A parameter.')) >>> item Item('[x](x)', 'int') @@ -161,7 +168,7 @@ class Item(Type): """ markdown: str = field(default="", init=False) - type: Type = field(default_factory=Type) + type: Type = field(default_factory=Type) # noqa: A003 description: Inline = field(default_factory=Inline) kind: str = "" @@ -186,10 +193,11 @@ def set_html(self, html: str): html = html.replace("", "__").replace("", "__") super().set_html(html) - def to_tuple(self) -> Tuple[str, str, str]: + def to_tuple(self) -> tuple[str, str, str]: """Returns a tuple of (name, type, description). - Examples: + Examples + -------- >>> item = Item('[x](x)', 'int', 'A parameter.') >>> item.to_tuple() ('[x](x)', 'int', 'A parameter.') @@ -200,11 +208,13 @@ def set_type(self, type: Type, force: bool = False): """Sets type. Args: + ---- item: Type instance. force: If True, overwrite self regardless of existing type and description. See Also: + -------- * Item.update_ """ if not force and self.type.name: @@ -216,11 +226,13 @@ def set_description(self, description: Inline, force: bool = False): """Sets description. Args: + ---- description: Inline instance. force: If True, overwrite self regardless of existing type and description. See Also: + -------- * Item.update_ """ if not force and self.description.name: @@ -232,11 +244,13 @@ def update(self, item: "Item", force: bool = False): """Updates type and description. Args: + ---- item: Item instance. force: If True, overwrite self regardless of existing type and description. Examples: + -------- >>> item = Item('x') >>> item2 = Item('x', 'int', 'description') >>> item.update(item2) @@ -267,10 +281,12 @@ class Section(Base): """Section class represents a section in docstring. Args: + ---- items: List for Arguments, Attributes, or Raises sections, *etc.* type: Type of self. Examples: + -------- >>> items = [Item('x'), Item('[y](a)'), Item('z')] >>> section = Section('Parameters', items=items) >>> section @@ -279,7 +295,7 @@ class Section(Base): [Item('[y](a)', '')] """ - items: List[Item] = field(default_factory=list) + items: list[Item] = field(default_factory=list) type: Type = field(default_factory=Type) def __post_init__(self): @@ -308,9 +324,11 @@ def __getitem__(self, name: str) -> Item: If there is no Item instance, a Item instance is newly created. Args: + ---- name: Item name. Examples: + -------- >>> section = Section("", items=[Item('x')]) >>> section['x'] Item('x', '') @@ -330,6 +348,7 @@ def __delitem__(self, name: str): """Delete an Item_ instance whose name is equal to `name`. Args: + ---- name: Item name. """ for k, item in enumerate(self.items): @@ -342,6 +361,7 @@ def __contains__(self, name: str) -> bool: """Returns True if there is an [Item]() instance whose name is equal to `name`. Args: + ---- name: Item name. """ for item in self.items: @@ -353,10 +373,12 @@ def set_item(self, item: Item, force: bool = False): """Sets an [Item](). Args: + ---- item: Item instance. force: If True, overwrite self regardless of existing item. Examples: + -------- >>> items = [Item('x', 'int'), Item('y', 'str', 'y')] >>> section = Section('Parameters', items=items) >>> section.set_item(Item('x', 'float', 'X')) @@ -370,6 +392,7 @@ def set_item(self, item: Item, force: bool = False): ['x', 'y', 'z'] See Also: + -------- * Section.update_ """ for k, x in enumerate(self.items): @@ -382,10 +405,12 @@ def update(self, section: "Section", force: bool = False): """Updates items. Args: + ---- section: Section instance. force: If True, overwrite items of self regardless of existing value. Examples: + -------- >>> s1 = Section('Parameters', items=[Item('a', 's'), Item('b', 'f')]) >>> s2 = Section('Parameters', items=[Item('a', 'i', 'A'), Item('x', 'd')]) >>> s1.update(s2) @@ -405,7 +430,8 @@ def update(self, section: "Section", force: bool = False): def merge(self, section: "Section", force: bool = False) -> "Section": """Returns a merged Section - Examples: + Examples + -------- >>> s1 = Section('Parameters', items=[Item('a', 's'), Item('b', 'f')]) >>> s2 = Section('Parameters', items=[Item('a', 'i'), Item('c', 'd')]) >>> s3 = s1.merge(s2) @@ -430,7 +456,8 @@ def merge(self, section: "Section", force: bool = False) -> "Section": def copy(self): """Returns a copy of the {class} instace. - Examples: + Examples + -------- >>> s = Section('E', 'markdown', [Item('a', 's'), Item('b', 'i')]) >>> s.copy() Section('E', num_items=2) @@ -447,10 +474,12 @@ class Docstring: """Docstring class represents a docstring of an object. Args: + ---- sections: List of Section instance. type: Type for Returns or Yields sections. Examples: + -------- Empty docstring: >>> docstring = Docstring() >>> assert not docstring @@ -481,7 +510,7 @@ class Docstring: ['', 'Parameters', 'Attributes', 'Todo'] """ - sections: List[Section] = field(default_factory=list) + sections: list[Section] = field(default_factory=list) type: Type = field(default_factory=Type) def __repr__(self): @@ -504,6 +533,7 @@ def __getitem__(self, name: str) -> Section: If there is no Section instance, a Section instance is newly created. Args: + ---- name: Section name. """ for section in self.sections: @@ -518,6 +548,7 @@ def __contains__(self, name) -> bool: equal to `name`. Args: + ---- name: Section name. """ for section in self.sections: @@ -535,10 +566,12 @@ def set_section( """Sets a [Section](). Args: + ---- section: Section instance. force: If True, overwrite self regardless of existing seciton. Examples: + -------- >>> items = [Item('x', 'int'), Item('y', 'str', 'y')] >>> s1 = Section('Attributes', items=items) >>> items = [Item('x', 'str', 'X'), Item('z', 'str', 'z')] diff --git a/src/mkapi/core/docstring.py b/src/mkapi/core/docstring.py index 452b7b2e..26de8267 100644 --- a/src/mkapi/core/docstring.py +++ b/src/mkapi/core/docstring.py @@ -2,14 +2,15 @@ import inspect import re +from collections.abc import Iterator from dataclasses import is_dataclass -from typing import Any, Iterator, List, Tuple +from typing import Any from mkapi.core import preprocess from mkapi.core.base import Docstring, Inline, Item, Section, Type from mkapi.core.linker import get_link, replace_link from mkapi.core.object import get_mro -from mkapi.core.signature import get_signature +from mkapi.inspect.signature import get_signature from mkapi.utils import get_indent, join SECTIONS = [ @@ -42,13 +43,15 @@ def rename_section(name: str) -> str: return name -def section_heading(line: str) -> Tuple[str, str]: +def section_heading(line: str) -> tuple[str, str]: """Returns a tuple of (section name, style name). Args: + ---- line: Docstring line. Examples: + -------- >>> section_heading("Args:") ('Args', 'google') >>> section_heading("Raises") @@ -64,13 +67,15 @@ def section_heading(line: str) -> Tuple[str, str]: return "", "" -def split_section(doc: str) -> Iterator[Tuple[str, str, str]]: +def split_section(doc: str) -> Iterator[tuple[str, str, str]]: """Yields a tuple of (section name, contents, style). Args: + ---- doc: Docstring Examples: + -------- >>> doc = "abc\\n\\nArgs:\\n x: X\\n" >>> it = split_section(doc) >>> next(it) @@ -107,10 +112,11 @@ def split_section(doc: str) -> Iterator[Tuple[str, str, str]]: yield name, join(lines[start:]), style -def split_parameter(doc: str) -> Iterator[List[str]]: +def split_parameter(doc: str) -> Iterator[list[str]]: """Yields a list of parameter string. Args: + ---- doc: Docstring """ lines = [x.rstrip() for x in doc.split("\n")] @@ -125,10 +131,11 @@ def split_parameter(doc: str) -> Iterator[List[str]]: start = stop -def parse_parameter(lines: List[str], style: str) -> Item: +def parse_parameter(lines: list[str], style: str) -> Item: """Returns a Item instance that represents a parameter. Args: + ---- lines: Splitted parameter docstring lines. style: Docstring style. `google` or `numpy`. """ @@ -153,12 +160,12 @@ def parse_parameter(lines: List[str], style: str) -> Item: return Item(name, Type(type), Inline("\n".join(parsed))) -def parse_parameters(doc: str, style: str) -> List[Item]: +def parse_parameters(doc: str, style: str) -> list[Item]: """Returns a list of Item.""" return [parse_parameter(lines, style) for lines in split_parameter(doc)] -def parse_returns(doc: str, style: str) -> Tuple[str, str]: +def parse_returns(doc: str, style: str) -> tuple[str, str]: """Returns a tuple of (type, markdown).""" lines = doc.split("\n") if style == "google": @@ -203,7 +210,8 @@ def parse_bases(doc: Docstring, obj: Any): def parse_source(doc: Docstring, obj: Any): """Parses parameters' docstring to inspect type and description from source. - Examples: + Examples + -------- >>> from mkapi.core.base import Base >>> doc = Docstring() >>> parse_source(doc, Base) @@ -265,7 +273,7 @@ def postprocess(doc: Docstring, obj: Any): else: doc.type = Type(signature.returns) - sections: List[Section] = [] + sections: list[Section] = [] for section in doc.sections: if section.name not in ["Example", "Examples"]: for base in section: diff --git a/src/mkapi/core/linker.py b/src/mkapi/core/linker.py index 5537e3ea..f89e6ebf 100644 --- a/src/mkapi/core/linker.py +++ b/src/mkapi/core/linker.py @@ -1,35 +1,44 @@ -"""This module provides functions that relate to link.""" +"""Provide functions that relate to linking functionality.""" import os import re from html.parser import HTMLParser -from typing import Any, Dict, List +from pathlib import Path +from typing import Any from mkapi.core.object import get_fullname -from mkapi.core.regex import LINK_PATTERN + +LINK_PATTERN = re.compile(r"\[(.+?)\]\((.+?)\)") +REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") -def link(name: str, href: str) -> str: - """Reutrns Markdown link with a mark that indicates this link was created by MkApi. +def link(name: str, ref: str) -> str: + """Return Markdown link with a mark that indicates this link was created by MkAPI. Args: + ---- name: Link name. - href: Reference. + ref: Reference. Examples: + -------- >>> link('abc', 'xyz') '[abc](!xyz)' """ - return f"[{name}](!{href})" + return f"[{name}](!{ref})" -def get_link(obj: Any, include_module: bool = False) -> str: - """Returns Markdown link for object, if possible. +def get_link(obj: type, *, include_module: bool = False) -> str: + """Return Markdown link for object, if possible. Args: + ---- obj: Object include_module: If True, link text includes module path. Examples: + -------- + >>> get_link(int) + 'int' >>> get_link(get_fullname) '[get_fullname](!mkapi.core.object.get_fullname)' >>> get_link(get_fullname, include_module=True) @@ -46,35 +55,33 @@ def get_link(obj: Any, include_module: bool = False) -> str: module = obj.__module__ if module == "builtins": return name - fullname = ".".join([module, name]) - if include_module: - text = fullname - else: - text = name + fullname = f"{module}.{name}" + text = fullname if include_module else name if obj.__name__.startswith("_"): return text - else: - return link(text, fullname) + return link(text, fullname) -def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: List[str]) -> str: - """Reutrns resolved link. +def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> str: + """Reutrn resolved link. Args: + ---- markdown: Markdown source. abs_src_path: Absolute source path of Markdown. abs_api_paths: A list of API paths. Examples: + -------- >>> abs_src_path = '/src/examples/example.md' >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] >>> resolve_link('[abc](!b.c.d)', abs_src_path, abs_api_paths) '[abc](../../api/b.c#b.c.d)' """ - def replace(match): + def replace(match: re.Match) -> str: name, href = match.groups() - if href.startswith("!!"): # Just for MkApi documentation. + if href.startswith("!!"): # Just for MkAPI documentation. href = href[2:] return f"[{name}]({href})" if href.startswith("!"): @@ -86,40 +93,39 @@ def replace(match): href = resolve_href(href, abs_src_path, abs_api_paths) if href: return f"[{name}]({href})" - elif from_mkapi: + if from_mkapi: return name - else: - return match.group() + return match.group() return re.sub(LINK_PATTERN, replace, markdown) -def resolve_href(name: str, abs_src_path: str, abs_api_paths: List[str]) -> str: +def resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: if not name: return "" - abs_api_path = match_last(name, abs_api_paths) + abs_api_path = _match_last(name, abs_api_paths) if not abs_api_path: return "" - relpath = os.path.relpath(abs_api_path, os.path.dirname(abs_src_path)) + relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) relpath = relpath.replace("\\", "/") - return "#".join([relpath, name]) + return f"{relpath}#{name}" -def match_last(name: str, abs_api_paths: List[str]) -> str: +def _match_last(name: str, abs_api_paths: list[str]) -> str: match = "" for abs_api_path in abs_api_paths: - dirname, path = os.path.split(abs_api_path) + _, path = os.path.split(abs_api_path) if name.startswith(path[:-3]): match = abs_api_path return match class _ObjectParser(HTMLParser): - def feed(self, html): + def feed(self, html: str) -> dict[str, Any]: self.context = {"href": [], "heading_id": ""} super().feed(html) href = self.context["href"] - if len(href) == 2: + if len(href) == 2: # noqa: PLR2004 prefix_url, name_url = href elif len(href) == 1: prefix_url, name_url = "", href[0] @@ -130,7 +136,7 @@ def feed(self, html): del self.context["href"] return self.context - def handle_starttag(self, tag, attrs): + def handle_starttag(self, tag: str, attrs: list[str]) -> None: context = self.context if tag == "p": context["level"] = 0 @@ -151,13 +157,15 @@ def handle_starttag(self, tag, attrs): parser = _ObjectParser() -def resolve_object(html: str) -> Dict[str, Any]: - """Reutrns an object context dictionary. +def resolve_object(html: str) -> dict[str, Any]: + """Reutrn an object context dictionary. Args: + ---- html: HTML source. Examples: + -------- >>> resolve_object("

pn

") {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} >>> resolve_object("

pn

") @@ -167,32 +175,31 @@ def resolve_object(html: str) -> Dict[str, Any]: return parser.feed(html) -REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") - - -def replace_link(obj: Any, markdown: str) -> str: - """Returns a replaced link with object full name. +def replace_link(obj: object, markdown: str) -> str: + """Return a replaced link with object's full name. Args: + ---- obj: Object that has a module. markdown: Markdown Examples: + -------- >>> from mkapi.core.object import get_object >>> obj = get_object('mkapi.core.structure.Object') >>> replace_link(obj, '[Signature]()') - '[Signature](!mkapi.core.signature.Signature)' + '[Signature](!mkapi.inspect.signature.Signature)' >>> replace_link(obj, '[](Signature)') - '[Signature](!mkapi.core.signature.Signature)' + '[Signature](!mkapi.inspect.signature.Signature)' >>> replace_link(obj, '[text](Signature)') - '[text](!mkapi.core.signature.Signature)' + '[text](!mkapi.inspect.signature.Signature)' >>> replace_link(obj, '[dummy.Dummy]()') '[dummy.Dummy]()' >>> replace_link(obj, 'Signature_') - '[Signature](!mkapi.core.signature.Signature)' + '[Signature](!mkapi.inspect.signature.Signature)' """ - def replace(match): + def replace(match: re.Match) -> str: text, name, rest = match.groups() if rest: name, text = rest, "" @@ -201,9 +208,6 @@ def replace(match): fullname = get_fullname(obj, name) if fullname == "": return match.group() - else: - if text: - name = text - return link(name, fullname) + return link(text or name, fullname) return re.sub(REPLACE_LINK_PATTERN, replace, markdown) diff --git a/src/mkapi/core/object.py b/src/mkapi/core/object.py index fc93d851..acab74b4 100644 --- a/src/mkapi/core/object.py +++ b/src/mkapi/core/object.py @@ -1,4 +1,4 @@ -"""This module provides utility functions that relate to object.""" +"""Utility functions relating to object.""" import abc import importlib import inspect @@ -9,9 +9,11 @@ def get_object(name: str) -> Any: """Reutrns an object specified by `name`. Args: + ---- name: Object name. Examples: + -------- >>> import inspect >>> obj = get_object('mkapi.core') >>> inspect.ismodule(obj) @@ -43,10 +45,12 @@ def get_fullname(obj: Any, name: str) -> str: """Reutrns an object full name specified by `name`. Args: + ---- obj: Object that has a module. name: Object name in the module. Examples: + -------- >>> obj = get_object('mkapi.core.base.Item') >>> get_fullname(obj, 'Section') 'mkapi.core.base.Section' @@ -71,13 +75,15 @@ def get_fullname(obj: Any, name: str) -> str: return ".".join(split_prefix_and_name(obj)) -def split_prefix_and_name(obj: Any) -> Tuple[str, str]: +def split_prefix_and_name(obj: Any) -> tuple[str, str]: """Splits an object full name into prefix and name. Args: + ---- obj: Object that has a module. Examples: + -------- >>> import inspect >>> obj = get_object('mkapi.core') >>> split_prefix_and_name(obj) @@ -113,7 +119,7 @@ def get_qualname(obj: Any): return "" -def get_sourcefile_and_lineno(obj: Any) -> Tuple[str, int]: +def get_sourcefile_and_lineno(obj: Any) -> tuple[str, int]: try: sourcefile = inspect.getsourcefile(obj) or "" except TypeError: @@ -136,12 +142,13 @@ def get_mro(obj): return objs -def get_sourcefiles(obj: Any) -> List[str]: +def get_sourcefiles(obj: Any) -> list[str]: """Returns a list of source file. If `obj` is a class, source files of its superclasses are also included. Args: + ---- obj: Object name. """ if inspect.isclass(obj) and hasattr(obj, "mro"): @@ -164,10 +171,12 @@ def from_object(obj: Any) -> bool: """Returns True, if the docstring of `obj` is the same as that of `object`. Args: + ---- name: Object name. obj: Object. Examples: + -------- >>> class A: pass >>> from_object(A.__call__) True @@ -187,7 +196,8 @@ def from_object(obj: Any) -> bool: def get_origin(obj: Any) -> Any: """Returns an original object. - Examples: + Examples + -------- >>> class A: ... @property ... def x(self): diff --git a/src/mkapi/core/page.py b/src/mkapi/core/page.py index 521fd8f2..abd67657 100644 --- a/src/mkapi/core/page.py +++ b/src/mkapi/core/page.py @@ -1,7 +1,7 @@ -"""This module provides a Page class that works with other converter.""" +"""Provide a Page class that works with other converter.""" import re +from collections.abc import Iterator from dataclasses import InitVar, dataclass, field -from typing import Iterator, List, Tuple, Union from mkapi import utils from mkapi.core import postprocess @@ -10,7 +10,12 @@ from mkapi.core.inherit import inherit from mkapi.core.linker import resolve_link from mkapi.core.node import Node, get_node -from mkapi.core.regex import MKAPI_PATTERN, NODE_PATTERN, node_markdown + +MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) +NODE_PATTERN = re.compile( + r"(.*?)", + re.MULTILINE | re.DOTALL, +) @dataclass @@ -18,32 +23,36 @@ class Page: """Page class works with [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). Args: + ---- source (str): Markdown source. abs_src_path: Absolute source path of Markdown. abs_api_paths: A list of API paths. Attributes: + ---------- markdown: Converted Markdown including API documentation. nodes: A list of Node instances. """ source: InitVar[str] abs_src_path: str - abs_api_paths: List[str] = field(default_factory=list, repr=False) - filters: List[str] = field(default_factory=list, repr=False) + abs_api_paths: list[str] = field(default_factory=list, repr=False) + filters: list[str] = field(default_factory=list, repr=False) markdown: str = field(init=False, repr=False) - nodes: List[Union[Node, Code]] = field(default_factory=list, init=False, repr=False) - headings: List[Tuple[int, str]] = field( - default_factory=list, init=False, repr=False + nodes: list[Node | Code] = field(default_factory=list, init=False, repr=False) + headings: list[tuple[int, str]] = field( + default_factory=list, + init=False, + repr=False, ) - def __post_init__(self, source): + def __post_init__(self, source: str) -> None: self.markdown = "\n\n".join(self.split(source)) - def resolve_link(self, markdown: str): + def resolve_link(self, markdown: str) -> str: return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) - def resolve_link_from_base(self, base: Base): + def resolve_link_from_base(self, base: Base) -> str: if isinstance(base, Section) and base.name in ["Example", "Examples"]: return base.markdown return resolve_link(base.markdown, self.abs_src_path, self.abs_api_paths) @@ -86,16 +95,23 @@ def split(self, source: str) -> Iterator[str]: yield self.resolve_link(markdown) def content(self, html: str) -> str: - """Returns updated HTML to [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). + """Return updated HTML to [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). Args: + ---- html: Input HTML converted by MkDocs. """ - def replace(match): + def replace(match: re.Match) -> str: node = self.nodes[int(match.group(1))] filters = match.group(2).split("|") node.set_html(match.group(3)) return node.get_html(filters) return re.sub(NODE_PATTERN, replace, html) + + +def node_markdown(index: int, markdown: str, filters: list[str] | None = None) -> str: + """Return Markdown text for node.""" + fs = "|".join(filters) if filters else "" + return f"\n\n{markdown}\n\n" diff --git a/src/mkapi/core/regex.py b/src/mkapi/core/regex.py deleted file mode 100644 index 6c2df22a..00000000 --- a/src/mkapi/core/regex.py +++ /dev/null @@ -1,19 +0,0 @@ -import re -from typing import List - -LINK_PATTERN = re.compile(r"\[(.+?)\]\((.+?)\)") - -MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) - -NODE_PATTERN = re.compile( - r"(.*?)", - re.MULTILINE | re.DOTALL, -) - - -def node_markdown(index: int, markdown: str, filters: List[str] = None) -> str: - if filters: - fs = "|".join(filters) - else: - fs = "" - return f"\n\n{markdown}\n\n" diff --git a/src/mkapi/core/signature.py b/src/mkapi/core/signature.py deleted file mode 100644 index ef5c29bc..00000000 --- a/src/mkapi/core/signature.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Signature class that inspects object and creates signature and types.""" -import importlib -import inspect -from dataclasses import InitVar, dataclass, field, is_dataclass -from functools import lru_cache -from typing import Any, TypeVar, Union - -from mkapi.core import linker, preprocess -from mkapi.core.attribute import get_attributes -from mkapi.core.base import Inline, Item, Section, Type - - -@dataclass -class Signature: - """Signature class. - - Args: - ---- - obj: Object - - Attributes: - ---------- - signature: `inspect.Signature` instance. - parameters: Parameters section. - defaults: Default value dictionary. Key is parameter name and - value is default value. - attributes: Attributes section. - returns: Returned type string. Used in Returns section. - yields: Yielded type string. Used in Yields section. - """ - - obj: Any = field(default=None, repr=False) - signature: inspect.Signature | None = field(default=None, init=False) - parameters: Section = field(default_factory=Section, init=False) - defaults: dict[str, Any] = field(default_factory=dict, init=False) - attributes: Section = field(default_factory=Section, init=False) - returns: str = field(default="", init=False) - yields: str = field(default="", init=False) - - def __post_init__(self): - if self.obj is None: - return - try: - self.signature = inspect.signature(self.obj) - except (TypeError, ValueError): - self.set_attributes() - return - - items = [] - for name, parameter in self.signature.parameters.items(): - if name == "self": - continue - elif parameter.kind is inspect.Parameter.VAR_POSITIONAL: - name = "*" + name - elif parameter.kind is inspect.Parameter.VAR_KEYWORD: - name = "**" + name - if isinstance(parameter.annotation, str): - type = resolve_forward_ref(self.obj, parameter.annotation) - else: - type = to_string(parameter.annotation, obj=self.obj) - default = parameter.default - if default == inspect.Parameter.empty: - self.defaults[name] = default - else: - self.defaults[name] = f"{default!r}" - if not type: - type = "optional" - elif not type.endswith(", optional"): - type += ", optional" - items.append(Item(name, Type(type))) - self.parameters = Section("Parameters", items=items) - self.set_attributes() - return_annotation = self.signature.return_annotation - - if isinstance(return_annotation, str): - self.returns = resolve_forward_ref(self.obj, return_annotation) - else: - self.returns = to_string(return_annotation, "returns", obj=self.obj) - self.yields = to_string(return_annotation, "yields", obj=self.obj) - - def __contains__(self, name) -> bool: - return name in self.parameters - - def __getitem__(self, name): - return getattr(self, name.lower()) - - def __str__(self): - args = self.arguments - if args is None: - return "" - else: - return "(" + ", ".join(args) + ")" - - @property - def arguments(self) -> list[str] | None: - """Returns arguments list.""" - if self.obj is None or not callable(self.obj): - return None - - args = [] - for item in self.parameters.items: - arg = item.name - if self.defaults[arg] != inspect.Parameter.empty: - arg += "=" + self.defaults[arg] - args.append(arg) - return args - - def set_attributes(self): - """Examples - >>> from mkapi.core.base import Base - >>> s = Signature(Base) - >>> s.parameters['name'].to_tuple() - ('name', 'str, optional', 'Name of self.') - >>> s.attributes['html'].to_tuple() - ('html', 'str', 'HTML output after conversion.') - """ - items = [] - for name, (type, description) in get_attributes(self.obj).items(): - if isinstance(type, str) and type: - type = resolve_forward_ref(self.obj, type) - else: - type = to_string(type, obj=self.obj) if type else "" - if not type: - type, description = preprocess.split_type(description) - - item = Item(name, Type(type), Inline(description)) - if is_dataclass(self.obj): - if name in self.parameters: - self.parameters[name].set_description(item.description) - if self.obj.__dataclass_fields__[name].type != InitVar: - items.append(item) - else: - items.append(item) - self.attributes = Section("Attributes", items=items) - - def split(self, sep=","): - return str(self).split(sep) - - -def to_string(annotation, kind: str = "returns", obj=None) -> str: - """Return string expression of annotation. - - If possible, type string includes link. - - Args: - ---- - annotation: Annotation - kind: 'returns' or 'yields' - - Examples: - -------- - >>> from typing import Callable, Iterator, List - >>> to_string(Iterator[str]) - 'iterator of str' - >>> to_string(Iterator[str], 'yields') - 'str' - >>> to_string(Callable) - 'callable' - >>> to_string(Callable[[int, float], str]) - 'callable(int, float: str)' - >>> from mkapi.core.node import Node - >>> to_string(List[Node]) - 'list of [Node](!mkapi.core.node.Node)' - """ - if kind == "yields": - if hasattr(annotation, "__args__") and annotation.__args__: - if len(annotation.__args__) == 1: - return to_string(annotation.__args__[0], obj=obj) - return to_string(annotation, obj=obj) - return "" - - if annotation == ...: - return "..." - if hasattr(annotation, "__forward_arg__"): - return resolve_forward_ref(obj, annotation.__forward_arg__) - if annotation == inspect.Parameter.empty or annotation is None: - return "" - name = linker.get_link(annotation) - if name: - return name - if not hasattr(annotation, "__origin__"): - return str(annotation).replace("typing.", "").lower() - origin = annotation.__origin__ - if origin is Union: - return union(annotation, obj=obj) - if origin is tuple: - args = [to_string(x, obj=obj) for x in annotation.__args__] - if args: - return "(" + ", ".join(args) + ")" - else: - return "tuple" - if origin is dict: - if type(annotation.__args__[0]) == TypeVar: - return "dict" - args = [to_string(x, obj=obj) for x in annotation.__args__] - if args: - return "dict(" + ": ".join(args) + ")" - else: - return "dict" - if not hasattr(annotation, "__args__"): - return "" - if len(annotation.__args__) == 0: - return annotation.__origin__.__name__.lower() - if len(annotation.__args__) == 1: - return a_of_b(annotation, obj=obj) - else: - return to_string_args(annotation, obj=obj) - - -def a_of_b(annotation, obj=None) -> str: - """Return "A of B" style string. - - Args: - ---- - annotation: Annotation - - Examples: - -------- - >>> from typing import List, Iterable, Iterator - >>> a_of_b(List[str]) - 'list of str' - >>> a_of_b(List[List[str]]) - 'list of list of str' - >>> a_of_b(Iterable[int]) - 'iterable of int' - >>> a_of_b(Iterator[float]) - 'iterator of float' - """ - origin = annotation.__origin__ - if not hasattr(origin, "__name__"): - return "" - name = origin.__name__.lower() - if type(annotation.__args__[0]) == TypeVar: - return name - type_ = f"{name} of " + to_string(annotation.__args__[0], obj=obj) - if type_.endswith(" of T"): - return name - return type_ - - -def union(annotation, obj=None) -> str: - """Return a string for union annotation. - - Args: - ---- - annotation: Annotation - - Examples: - -------- - >>> from typing import List, Optional, Tuple, Union - >>> union(Optional[List[str]]) - 'list of str, optional' - >>> union(Union[str, int]) - 'str or int' - >>> union(Union[str, int, float]) - 'str, int, or float' - >>> union(Union[List[str], Tuple[int, int]]) - 'Union(list of str, (int, int))' - """ - args = annotation.__args__ - if ( - len(args) == 2 - and hasattr(args[1], "__name__") - and args[1].__name__ == "NoneType" - ): - return to_string(args[0], obj=obj) + ", optional" - else: - args = [to_string(x, obj=obj) for x in args] - if all(" " not in arg for arg in args): - if len(args) == 2: - return " or ".join(args) - else: - return ", ".join(args[:-1]) + ", or " + args[-1] - else: - return "Union(" + ", ".join(to_string(x, obj=obj) for x in args) + ")" - - -def to_string_args(annotation, obj=None) -> str: - """Return a string for callable and generator annotation. - - Args: - ---- - annotation: Annotation - - Examples: - -------- - >>> from typing import Callable, List, Tuple, Any - >>> from typing import Generator, AsyncGenerator - >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) - 'callable(int, list of str: (int, int))' - >>> to_string_args(Callable[[int], Any]) - 'callable(int)' - >>> to_string_args(Callable[[str], None]) - 'callable(str)' - >>> to_string_args(Callable[..., int]) - 'callable(...: int)' - >>> to_string_args(Generator[int, float, str]) - 'generator(int, float, str)' - >>> to_string_args(AsyncGenerator[int, float]) - 'asyncgenerator(int, float)' - """ - - def to_string_with_prefix(annotation, prefix: str = ",") -> str: - s = to_string(annotation, obj=obj) - if s in ["NoneType", "any"]: - return "" - return f"{prefix} {s}" - - args = annotation.__args__ - name = annotation.__origin__.__name__.lower() - if name == "callable": - *args, returns = args - args = ", ".join(to_string(x, obj=obj) for x in args) - returns = to_string_with_prefix(returns, ":") - return f"{name}({args}{returns})" - if name == "generator": - arg, sends, returns = args - arg = to_string(arg, obj=obj) - sends = to_string_with_prefix(sends) - returns = to_string_with_prefix(returns) - if not sends and returns: - sends = "," - return f"{name}({arg}{sends}{returns})" - if name == "asyncgenerator": - arg, sends = args - arg = to_string(arg, obj=obj) - sends = to_string_with_prefix(sends) - return f"{name}({arg}{sends})" - return "" - - -def resolve_forward_ref(obj: Any, name: str) -> str: - """Return a resolved name for `typing.ForwardRef`. - - Args: - ---- - obj: Object - name: Forward reference name. - - Examples: - -------- - >>> from mkapi.core.base import Docstring - >>> resolve_forward_ref(Docstring, 'Docstring') - '[Docstring](!mkapi.core.base.Docstring)' - >>> resolve_forward_ref(Docstring, 'invalid_object_name') - 'invalid_object_name' - """ - if obj is None or not hasattr(obj, "__module__"): - return name - module = importlib.import_module(obj.__module__) - globals = dict(inspect.getmembers(module)) - try: - type = eval(name, globals) - except NameError: - return name - else: - return to_string(type) - - -@lru_cache(maxsize=1000) -def get_signature(obj: Any) -> Signature: - return Signature(obj) diff --git a/src/mkapi/core/structure.py b/src/mkapi/core/structure.py index ce2fccd5..bcc7d908 100644 --- a/src/mkapi/core/structure.py +++ b/src/mkapi/core/structure.py @@ -1,14 +1,19 @@ """This module provides base class of [Node](mkapi.core.node.Node) and -[Module](mkapi.core.module.Module).""" +[Module](mkapi.core.module.Module). +""" +from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Any, Iterator, List, Union +from typing import Any, Union from mkapi.core.base import Base, Type from mkapi.core.docstring import Docstring, get_docstring -from mkapi.core.object import (get_origin, get_qualname, - get_sourcefile_and_lineno, - split_prefix_and_name) -from mkapi.core.signature import Signature, get_signature +from mkapi.core.object import ( + get_origin, + get_qualname, + get_sourcefile_and_lineno, + split_prefix_and_name, +) +from mkapi.inspect.signature import Signature, get_signature "a.b.c".rpartition(".") @@ -18,6 +23,7 @@ class Object(Base): """Object class represents an object. Args: + ---- name: Object name. prefix: Object prefix. qualname: Qualified name. @@ -25,6 +31,7 @@ class Object(Base): signature: Signature if object is module or callable. Attributes: + ---------- id: ID attribute of HTML. type: Type for missing Returns and Yields sections. """ @@ -72,9 +79,11 @@ class Tree: and [Module](mkapi.core.module.Module). Args: + ---- obj: Object. Attributes: + ---------- sourcefile: Source file path. lineno: Line number. object: Object instance. @@ -89,7 +98,7 @@ class Tree: object: Object = field(init=False) docstring: Docstring = field(init=False) parent: Any = field(default=None, init=False) - members: List[Any] = field(init=False) + members: list[Any] = field(init=False) def __post_init__(self): obj = get_origin(self.obj) @@ -99,7 +108,11 @@ def __post_init__(self): kind = self.get_kind() signature = get_signature(obj) self.object = Object( - prefix=prefix, name=name, qualname=qualname, kind=kind, signature=signature, + prefix=prefix, + name=name, + qualname=qualname, + kind=kind, + signature=signature, ) self.docstring = get_docstring(obj) self.obj = obj @@ -114,13 +127,14 @@ def __repr__(self): numbers = len(self.members) return f"{class_name}({id!r}, num_sections={sections}, num_members={numbers})" - def __getitem__(self, index: Union[int, str, List[str]]): + def __getitem__(self, index: Union[int, str, list[str]]): """Returns a member {class} instance. If `index` is str, a member Tree instance whose name is equal to `index` is returned. - Raises: + Raises + ------ IndexError: If no member found. """ if isinstance(index, list): @@ -151,7 +165,7 @@ def get_kind(self) -> str: """Returns kind of self.""" raise NotImplementedError - def get_members(self) -> List["Tree"]: + def get_members(self) -> list["Tree"]: """Returns a list of members.""" raise NotImplementedError diff --git a/src/mkapi/examples/__init__.py b/src/mkapi/examples/__init__.py new file mode 100644 index 00000000..2926481d --- /dev/null +++ b/src/mkapi/examples/__init__.py @@ -0,0 +1 @@ +"""Example package for MkAPI test.""" diff --git a/examples/__init__.py b/src/mkapi/examples/cls/__init__.py similarity index 100% rename from examples/__init__.py rename to src/mkapi/examples/cls/__init__.py diff --git a/examples/appendix/abc.py b/src/mkapi/examples/cls/abc.py similarity index 87% rename from examples/appendix/abc.py rename to src/mkapi/examples/cls/abc.py index d3c3cac8..11dc8763 100644 --- a/examples/appendix/abc.py +++ b/src/mkapi/examples/cls/abc.py @@ -6,14 +6,17 @@ class AbstractMethodTypeExample(ABC): def method(self): """Method.""" + return self @classmethod def class_method(cls): """Class method.""" + return cls @staticmethod def static_method(): """Static method.""" + return True @abstractmethod def abstract_method(self): @@ -29,17 +32,17 @@ def abstract_class_method(cls): def abstract_static_method(): """Abstract static method.""" - @property # type:ignore + @property @abstractmethod def abstract_read_only_property(self): """Abstract read only property.""" - @property # type:ignore + @property @abstractmethod def abstract_read_write_property(self): """Abstract read write property.""" - @abstract_read_write_property.setter # type:ignore + @abstract_read_write_property.setter @abstractmethod def abstract_read_write_property(self, val): pass diff --git a/examples/appendix/decorated.py b/src/mkapi/examples/cls/decorated.py similarity index 75% rename from examples/appendix/decorated.py rename to src/mkapi/examples/cls/decorated.py index d0d38ee6..034827db 100644 --- a/examples/appendix/decorated.py +++ b/src/mkapi/examples/cls/decorated.py @@ -1,7 +1,7 @@ """Decorator examples.""" import pytest -from appendix.decorator import deco_with_wraps, deco_without_wraps +from mkapi.examples.cls.decorator import deco_with_wraps, deco_without_wraps @deco_without_wraps @@ -20,14 +20,14 @@ def func_with_wraps_double(): """Doubly decorated function with `wraps`.""" -@pytest.fixture +@pytest.fixture() def fixture(): """Fixture.""" - yield 1 + return 1 -@pytest.fixture +@pytest.fixture() @deco_with_wraps def fixture_with_wraps(): """Fixture.""" - yield 1 + return 1 diff --git a/examples/appendix/decorator.py b/src/mkapi/examples/cls/decorator.py similarity index 100% rename from examples/appendix/decorator.py rename to src/mkapi/examples/cls/decorator.py diff --git a/examples/appendix/inherit.py b/src/mkapi/examples/cls/inherit.py similarity index 100% rename from examples/appendix/inherit.py rename to src/mkapi/examples/cls/inherit.py diff --git a/examples/appendix/member_order_base.py b/src/mkapi/examples/cls/member_order_base.py similarity index 100% rename from examples/appendix/member_order_base.py rename to src/mkapi/examples/cls/member_order_base.py diff --git a/examples/appendix/member_order_sub.py b/src/mkapi/examples/cls/member_order_sub.py similarity index 79% rename from examples/appendix/member_order_sub.py rename to src/mkapi/examples/cls/member_order_sub.py index 98ac23e9..dae57cb3 100644 --- a/examples/appendix/member_order_sub.py +++ b/src/mkapi/examples/cls/member_order_sub.py @@ -1,4 +1,4 @@ -from appendix.member_order_base import A +from mkapi.examples.cls.member_order_base import A class B: diff --git a/examples/appendix/method.py b/src/mkapi/examples/cls/method.py similarity index 63% rename from examples/appendix/method.py rename to src/mkapi/examples/cls/method.py index 9bc8aeae..e19fcd49 100644 --- a/examples/appendix/method.py +++ b/src/mkapi/examples/cls/method.py @@ -1,12 +1,15 @@ class MethodTypeExample: """Example class.""" + def __init__(self, x: int): + self._x = x + def method(self, x): """Method.""" def generator(self, x): """Generator.""" - yield 1 + yield x + 1 @classmethod def class_method(cls, x): @@ -17,13 +20,15 @@ def static_method(x): """Static method.""" @property - def read_only_property(x): + def read_only_property(self): """Read only property.""" + return self._x @property - def read_write_property(x): + def read_write_property(self): """Read write property.""" + return self._x @read_write_property.setter - def read_write_property(x): - pass + def read_write_property(self, x): + self._x = x diff --git a/examples/custom.py b/src/mkapi/examples/custom.py similarity index 100% rename from examples/custom.py rename to src/mkapi/examples/custom.py diff --git a/examples/filter.py b/src/mkapi/examples/filter.py similarity index 72% rename from examples/filter.py rename to src/mkapi/examples/filter.py index b7d3a243..dee0dbf2 100644 --- a/examples/filter.py +++ b/src/mkapi/examples/filter.py @@ -1,8 +1,9 @@ -from typing import Iterator +from collections.abc import Iterator def func(x: int): """Function.""" + return x def gen() -> Iterator[int]: diff --git a/examples/inherit.py b/src/mkapi/examples/inherit.py similarity index 83% rename from examples/inherit.py rename to src/mkapi/examples/inherit.py index a80c9b58..fe49daf3 100644 --- a/examples/inherit.py +++ b/src/mkapi/examples/inherit.py @@ -15,10 +15,10 @@ class Base: """ name: str - type: Type + type: Type # noqa: A003 def set_name(self, name: str): - """Sets name. + """Set name. Args: name: A New name. @@ -26,7 +26,7 @@ def set_name(self, name: str): self.name = name def get(self): - """Returns {class} instace.""" + """Return {class} instace.""" return self @@ -44,5 +44,5 @@ class Item(Base): markdown: str def set_name(self, name: str): - """Sets name in upper case.""" + """Set name in upper case.""" self.name = name.upper() diff --git a/examples/inherit_comment.py b/src/mkapi/examples/inherit_comment.py similarity index 80% rename from examples/inherit_comment.py rename to src/mkapi/examples/inherit_comment.py index 2213e9dd..4131ce05 100644 --- a/examples/inherit_comment.py +++ b/src/mkapi/examples/inherit_comment.py @@ -8,10 +8,10 @@ class Base: """Base class.""" name: str #: Object name. - type: Type #: Object type. + type: Type #: Object type. # noqa: A003 def set_name(self, name: str): - """Sets name. + """Set name. Args: name: A New name. @@ -26,5 +26,5 @@ class Item(Base): markdown: str #: Object Markdown. def set_name(self, name: str): - """Sets name in upper case.""" + """Set name in upper case.""" self.name = name.upper() diff --git a/src/mkapi/examples/inspect.py b/src/mkapi/examples/inspect.py new file mode 100644 index 00000000..27a6483f --- /dev/null +++ b/src/mkapi/examples/inspect.py @@ -0,0 +1,12 @@ +from typing import Any + + +def fs( + x, + i: int, + l: list[str], + t: tuple[int, str], + d: dict[str, Any], + s: str = "d", +) -> list[str]: + return ["x"] diff --git a/examples/appendix/__init__.py b/src/mkapi/examples/link/__init__.py similarity index 100% rename from examples/appendix/__init__.py rename to src/mkapi/examples/link/__init__.py diff --git a/examples/link/fullname.py b/src/mkapi/examples/link/fullname.py similarity index 100% rename from examples/link/fullname.py rename to src/mkapi/examples/link/fullname.py diff --git a/examples/link/qualname.py b/src/mkapi/examples/link/qualname.py similarity index 100% rename from examples/link/qualname.py rename to src/mkapi/examples/link/qualname.py diff --git a/examples/meta.py b/src/mkapi/examples/meta.py similarity index 94% rename from examples/meta.py rename to src/mkapi/examples/meta.py index 7f981f5c..f2d0e272 100644 --- a/examples/meta.py +++ b/src/mkapi/examples/meta.py @@ -1,7 +1,7 @@ class A(type): """A""" - def f(self): + def f(cls): """f""" diff --git a/src/mkapi/examples/styles/__init__.py b/src/mkapi/examples/styles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/google_style.py b/src/mkapi/examples/styles/google.py similarity index 88% rename from examples/google_style.py rename to src/mkapi/examples/styles/google.py index 7dc1ee8b..2d5af331 100644 --- a/examples/google_style.py +++ b/src/mkapi/examples/styles/google.py @@ -1,11 +1,11 @@ """Module level docstring.""" +from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Dict, Iterator, List, Tuple #: The first module level attribute. Comment *before* attribute. first_attribute: int = 1 second_attribute = "abc" #: str: The second module level attribute. *Inline* style. -third_attribute: List[int] = [1, 2, 3] +third_attribute: list[int] = [1, 2, 3] """The third module level attribute. Docstring *after* attribute. Multiple paragraphs are supported. @@ -63,19 +63,19 @@ class ExampleClass: ValueError: If the length of `x` is equal to 0. """ - def __init__(self, x: List[int], y: Tuple[str, int]): - if len(x) == 0: - raise ValueError() + def __init__(self, x: list[int], y: tuple[str, int]): + if len(x) == 0 or y[1] == 0: + raise ValueError self.a: str = "abc" #: The first attribute. Comment *inline* with attribute. #: The second attribute. Comment *before* attribute. - self.b: Dict[str, int] = {"a": 1} + self.b: dict[str, int] = {"a": 1} self.c = None """int, optional: The third attribute. Docstring *after* attribute. Multiple paragraphs are supported.""" self.d = 100 # Not attribute description because ':' is missing. - def message(self, n: int) -> List[str]: + def message(self, n: int) -> list[str]: """Returns a message list. Args: @@ -89,7 +89,7 @@ def readonly_property(self): return "readonly property" @property - def readwrite_property(self) -> List[int]: + def readwrite_property(self) -> list[int]: """Read-write property documentation.""" return [1, 2, 3] diff --git a/examples/numpy_style.py b/src/mkapi/examples/styles/numpy.py similarity index 86% rename from examples/numpy_style.py rename to src/mkapi/examples/styles/numpy.py index a9de9a37..7469347b 100644 --- a/examples/numpy_style.py +++ b/src/mkapi/examples/styles/numpy.py @@ -1,6 +1,6 @@ """Module level docstring.""" +from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Dict, Iterator, List, Tuple def add(x: int, y: int = 1) -> int: @@ -31,7 +31,7 @@ def add(x: int, y: int = 1) -> int: Note ---- - MkApi doesn't check an underline that follows a section heading. + MkAPI doesn't check an underline that follows a section heading. Just skip one line. """ return x + y @@ -70,19 +70,19 @@ class ExampleClass: If the length of `x` is equal to 0. """ - def __init__(self, x: List[int], y: Tuple[str, int]): - if len(x) == 0: - raise ValueError() + def __init__(self, x: list[int], y: tuple[str, int]): + if len(x) == 0 or y[1] == 0: + raise ValueError self.a: str = "abc" #: The first attribute. Comment *inline* with attribute. #: The second attribute. Comment *before* attribute. - self.b: Dict[str, int] = {"a": 1} + self.b: dict[str, int] = {"a": 1} self.c = None """int, optional: The third attribute. Docstring *after* attribute. Multiple paragraphs are supported.""" self.d = 100 # Not attribute description because ':' is missing. - def message(self, n: int) -> List[str]: + def message(self, n: int) -> list[str]: """Returns a message list. Parameters @@ -98,7 +98,7 @@ def readonly_property(self): return "readonly property" @property - def readwrite_property(self) -> List[int]: + def readwrite_property(self) -> list[int]: """Read-write property documentation.""" return [1, 2, 3] diff --git a/src/mkapi/inspect/__init__.py b/src/mkapi/inspect/__init__.py new file mode 100644 index 00000000..8fe7a65c --- /dev/null +++ b/src/mkapi/inspect/__init__.py @@ -0,0 +1 @@ +"""Inspection package.""" diff --git a/src/mkapi/inspect/annotation.py b/src/mkapi/inspect/annotation.py new file mode 100644 index 00000000..c6fa0190 --- /dev/null +++ b/src/mkapi/inspect/annotation.py @@ -0,0 +1,231 @@ +"""Annotation string.""" +import inspect + +from mkapi.core import linker + + +def to_string( + annotation, # noqa: ANN001 + kind: str = "returns", + obj: object = None, +) -> str | type[inspect._empty]: + """Return string expression of annotation. + + If possible, type string includes link. + + Args: + ---- + annotation: Annotation + kind: 'returns' or 'yields' + + Examples: + -------- + >>> from typing import Callable, Iterator, List + >>> to_string(Iterator[str]) + 'iterator of str' + >>> to_string(Iterator[str], 'yields') + 'str' + >>> to_string(Callable) + 'callable' + >>> to_string(Callable[[int, float], str]) + 'callable(int, float: str)' + >>> from mkapi.core.node import Node + >>> to_string(List[Node]) + 'list of [Node](!mkapi.core.node.Node)' + """ + empty = inspect.Parameter.empty + if annotation is empty: + return empty + if kind == "yields": + if hasattr(annotation, "__args__") and annotation.__args__: + if len(annotation.__args__) == 1: + return to_string(annotation.__args__[0], obj=obj) + return to_string(annotation, obj=obj) + return empty + + if annotation == ...: + return "..." + if hasattr(annotation, "__forward_arg__"): + return resolve_forward_ref(obj, annotation.__forward_arg__) + if annotation == inspect.Parameter.empty or annotation is None: + return "" + name = linker.get_link(annotation) + if name: + return name + if not hasattr(annotation, "__origin__"): + return str(annotation).replace("typing.", "").lower() + origin = annotation.__origin__ + if origin is Union: + return union(annotation, obj=obj) + if origin is tuple: + args = [to_string(x, obj=obj) for x in annotation.__args__] + if args: + return "(" + ", ".join(args) + ")" + else: + return "tuple" + if origin is dict: + if type(annotation.__args__[0]) == TypeVar: + return "dict" + args = [to_string(x, obj=obj) for x in annotation.__args__] + if args: + return "dict(" + ": ".join(args) + ")" + else: + return "dict" + if not hasattr(annotation, "__args__"): + return "" + if len(annotation.__args__) == 0: + return annotation.__origin__.__name__.lower() + if len(annotation.__args__) == 1: + return a_of_b(annotation, obj=obj) + else: + return to_string_args(annotation, obj=obj) + + +# def a_of_b(annotation, obj=None) -> str: +# """Return "A of B" style string. + +# Args: +# ---- +# annotation: Annotation + +# Examples: +# -------- +# >>> from typing import List, Iterable, Iterator +# >>> a_of_b(List[str]) +# 'list of str' +# >>> a_of_b(List[List[str]]) +# 'list of list of str' +# >>> a_of_b(Iterable[int]) +# 'iterable of int' +# >>> a_of_b(Iterator[float]) +# 'iterator of float' +# """ +# origin = annotation.__origin__ +# if not hasattr(origin, "__name__"): +# return "" +# name = origin.__name__.lower() +# if type(annotation.__args__[0]) == TypeVar: +# return name +# type_ = f"{name} of " + to_string(annotation.__args__[0], obj=obj) +# if type_.endswith(" of T"): +# return name +# return type_ + + +# def union(annotation, obj=None) -> str: +# """Return a string for union annotation. + +# Args: +# ---- +# annotation: Annotation + +# Examples: +# -------- +# >>> from typing import List, Optional, Tuple, Union +# >>> union(Optional[List[str]]) +# 'list of str, optional' +# >>> union(Union[str, int]) +# 'str or int' +# >>> union(Union[str, int, float]) +# 'str, int, or float' +# >>> union(Union[List[str], Tuple[int, int]]) +# 'Union(list of str, (int, int))' +# """ +# args = annotation.__args__ +# if ( +# len(args) == 2 +# and hasattr(args[1], "__name__") +# and args[1].__name__ == "NoneType" +# ): +# return to_string(args[0], obj=obj) + ", optional" +# else: +# args = [to_string(x, obj=obj) for x in args] +# if all(" " not in arg for arg in args): +# if len(args) == 2: +# return " or ".join(args) +# else: +# return ", ".join(args[:-1]) + ", or " + args[-1] +# else: +# return "Union(" + ", ".join(to_string(x, obj=obj) for x in args) + ")" + + +def to_string_args(annotation, obj=None) -> str: + """Return a string for callable and generator annotation. + + Args: + ---- + annotation: Annotation + + Examples: + -------- + >>> from typing import Callable, List, Tuple, Any + >>> from typing import Generator, AsyncGenerator + >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) + 'callable(int, list of str: (int, int))' + >>> to_string_args(Callable[[int], Any]) + 'callable(int)' + >>> to_string_args(Callable[[str], None]) + 'callable(str)' + >>> to_string_args(Callable[..., int]) + 'callable(...: int)' + >>> to_string_args(Generator[int, float, str]) + 'generator(int, float, str)' + >>> to_string_args(AsyncGenerator[int, float]) + 'asyncgenerator(int, float)' + """ + + def to_string_with_prefix(annotation, prefix: str = ",") -> str: + s = to_string(annotation, obj=obj) + if s in ["NoneType", "any"]: + return "" + return f"{prefix} {s}" + + args = annotation.__args__ + name = annotation.__origin__.__name__.lower() + if name == "callable": + *args, returns = args + args = ", ".join(to_string(x, obj=obj) for x in args) + returns = to_string_with_prefix(returns, ":") + return f"{name}({args}{returns})" + if name == "generator": + arg, sends, returns = args + arg = to_string(arg, obj=obj) + sends = to_string_with_prefix(sends) + returns = to_string_with_prefix(returns) + if not sends and returns: + sends = "," + return f"{name}({arg}{sends}{returns})" + if name == "asyncgenerator": + arg, sends = args + arg = to_string(arg, obj=obj) + sends = to_string_with_prefix(sends) + return f"{name}({arg}{sends})" + return "" + + +def resolve_forward_ref(obj: object, name: str) -> str: + """Return a resolved name for `typing.ForwardRef`. + + Args: + ---- + obj: Object + name: Forward reference name. + + Examples: + -------- + >>> from mkapi.core.base import Docstring + >>> resolve_forward_ref(Docstring, 'Docstring') + '[Docstring](!mkapi.core.base.Docstring)' + >>> resolve_forward_ref(Docstring, 'invalid_object_name') + 'invalid_object_name' + """ + if obj is None or not hasattr(obj, "__module__"): + return name + module = importlib.import_module(obj.__module__) + globals = dict(inspect.getmembers(module)) + try: + type = eval(name, globals) + except NameError: + return name + else: + return to_string(type) diff --git a/src/mkapi/inspect/signature.py b/src/mkapi/inspect/signature.py new file mode 100644 index 00000000..f0a5c82f --- /dev/null +++ b/src/mkapi/inspect/signature.py @@ -0,0 +1,175 @@ +"""Signature class that inspects object and creates signature and types.""" +import inspect +from dataclasses import InitVar, dataclass, field, is_dataclass +from functools import lru_cache +from typing import Any + +from mkapi.core import preprocess +from mkapi.core.attribute import get_attributes +from mkapi.core.base import Inline, Item, Section, Type + + +@dataclass +class Signature: + """Signature class. + + Args: + ---- + obj: Object + + Attributes: + ---------- + signature: `inspect.Signature` instance. + parameters: Parameters section. + defaults: Default value dictionary. Key is parameter name and + value is default value. + attributes: Attributes section. + returns: Returned type string. Used in Returns section. + yields: Yielded type string. Used in Yields section. + """ + + obj: Any = field(default=None, repr=False) + signature: inspect.Signature | None = field(default=None, init=False) + parameters: Section = field(default_factory=Section, init=False) + defaults: dict[str, Any] = field(default_factory=dict, init=False) + attributes: Section = field(default_factory=Section, init=False) + returns: str = field(default="", init=False) + yields: str = field(default="", init=False) + + def __post_init__(self): + if self.obj is None: + return + try: + self.signature = inspect.signature(self.obj) + except (TypeError, ValueError): + self.set_attributes() + return + + items = [] + for name, parameter in self.signature.parameters.items(): + if name == "self": + continue + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + name = "*" + name + elif parameter.kind is inspect.Parameter.VAR_KEYWORD: + name = "**" + name + if isinstance(parameter.annotation, str): + type = resolve_forward_ref(self.obj, parameter.annotation) + else: + type = to_string(parameter.annotation, obj=self.obj) + default = parameter.default + if default == inspect.Parameter.empty: + self.defaults[name] = default + else: + self.defaults[name] = f"{default!r}" + if not type: + type = "optional" + elif not type.endswith(", optional"): + type += ", optional" + items.append(Item(name, Type(type))) + self.parameters = Section("Parameters", items=items) + self.set_attributes() + return_annotation = self.signature.return_annotation + + if isinstance(return_annotation, str): + self.returns = resolve_forward_ref(self.obj, return_annotation) + else: + self.returns = to_string(return_annotation, "returns", obj=self.obj) + self.yields = to_string(return_annotation, "yields", obj=self.obj) + + def __contains__(self, name) -> bool: + return name in self.parameters + + def __getitem__(self, name): + return getattr(self, name.lower()) + + def __str__(self): + args = self.arguments + if args is None: + return "" + else: + return "(" + ", ".join(args) + ")" + + @property + def arguments(self) -> list[str] | None: + """Returns arguments list.""" + if self.obj is None or not callable(self.obj): + return None + + args = [] + for item in self.parameters.items: + arg = item.name + if self.defaults[arg] != inspect.Parameter.empty: + arg += "=" + self.defaults[arg] + args.append(arg) + return args + + def set_attributes(self): + """Examples + >>> from mkapi.core.base import Base + >>> s = Signature(Base) + >>> s.parameters['name'].to_tuple() + ('name', 'str, optional', 'Name of self.') + >>> s.attributes['html'].to_tuple() + ('html', 'str', 'HTML output after conversion.') + """ + items = [] + for name, (type, description) in get_attributes(self.obj).items(): + if isinstance(type, str) and type: + type = resolve_forward_ref(self.obj, type) + else: + type = to_string(type, obj=self.obj) if type else "" + if not type: + type, description = preprocess.split_type(description) + + item = Item(name, Type(type), Inline(description)) + if is_dataclass(self.obj): + if name in self.parameters: + self.parameters[name].set_description(item.description) + if self.obj.__dataclass_fields__[name].type != InitVar: + items.append(item) + else: + items.append(item) + self.attributes = Section("Attributes", items=items) + + def split(self, sep=","): + return str(self).split(sep) + + +def get_parameters(obj) -> tuple[list[Item], dict[str, Any]]: # noqa: ANN001 + """Return a tuple of parameters section and defalut values.""" + signature = inspect.signature(obj) + items: list[Item] = [] + defaults: dict[str, Any] = {} + + for name, parameter in signature.parameters.items(): + if name == "self": + continue + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + key = f"*{name}" + elif parameter.kind is inspect.Parameter.VAR_KEYWORD: + key = f"**{name}" + else: + key = name + if isinstance(parameter.annotation, str): + type_ = resolve_forward_ref(obj, parameter.annotation) + else: + type_ = to_string(parameter.annotation, obj=obj) + default = parameter.default + if default == inspect.Parameter.empty: + defaults[key] = default + else: + defaults[key] = f"{default!r}" + if not type_: + type_ = "optional" + elif not type_.endswith(", optional"): + type_ += ", optional" + items.append(Item(name, Type(type_))) + + return items, defaults + + +@lru_cache(maxsize=1000) +def get_signature(obj: object) -> Signature: + """Return a `Signature` object for `obj`.""" + return Signature(obj) diff --git a/src/mkapi/plugins/api.py b/src/mkapi/plugins/api.py index ebf42629..d712a2e7 100644 --- a/src/mkapi/plugins/api.py +++ b/src/mkapi/plugins/api.py @@ -31,7 +31,7 @@ def collect(path: str, docs_dir: str, config_dir, global_filters) -> Tuple[list, _, api_path, *paths, package_path = path.split("/") abs_api_path = os.path.join(docs_dir, api_path) if os.path.exists(abs_api_path): - logger.error(f"[MkApi] {abs_api_path} exists: Delete manually for safety.") + logger.error(f"[MkAPI] {abs_api_path} exists: Delete manually for safety.") sys.exit(1) os.mkdir(abs_api_path) os.mkdir(os.path.join(abs_api_path, "source")) @@ -97,4 +97,4 @@ def rmtree(path: str): try: shutil.rmtree(path) except PermissionError: - logger.warning(f"[MkApi] Couldn't delete directory: {path}") + logger.warning(f"[MkAPI] Couldn't delete directory: {path}") diff --git a/src/mkapi/plugins/mkdocs.py b/src/mkapi/plugins/mkdocs.py index 0984921b..5428f5cb 100644 --- a/src/mkapi/plugins/mkdocs.py +++ b/src/mkapi/plugins/mkdocs.py @@ -64,7 +64,7 @@ def on_config(self, config, **kwargs): kwargs["config"] = config if "mkapi" in params: kwargs["mkapi"] = self - logger.info(f"[MkApi] Calling user 'on_config' with {list(kwargs)}") + logger.info(f"[MkAPI] Calling user 'on_config' with {list(kwargs)}") config_ = on_config(**kwargs) if config_ is not None: config = config_ @@ -119,7 +119,7 @@ def on_page_markdown(self, markdown, page, config, files, **kwargs): return page.markdown def on_page_content(self, html, page, config, files, **kwargs): - """Merges html and MkApi's node structure.""" + """Merges html and MkAPI's node structure.""" if page.title: page.title = re.sub(r"<.*?>", "", page.title) abs_src_path = page.file.abs_src_path diff --git a/tests/conftest.py b/tests/conftest.py index 85324018..67f634d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,6 @@ -import sys - import pytest -sys.path.insert(0, "examples") - -import google_style as example # isort:skip +import mkapi.examples.styles.google as example @pytest.fixture(scope="session") @@ -23,10 +19,10 @@ def gen(): @pytest.fixture(scope="session") -def ExampleClass(): +def ExampleClass(): # noqa: N802 return example.ExampleClass @pytest.fixture(scope="session") -def ExampleDataClass(): +def ExampleDataClass(): # noqa: N802 return example.ExampleDataClass diff --git a/tests/core/test_core_attribute.py b/tests/core/test_core_attribute.py index efd293ce..2bbaf935 100644 --- a/tests/core/test_core_attribute.py +++ b/tests/core/test_core_attribute.py @@ -1,10 +1,8 @@ -import typing from dataclasses import dataclass -from typing import Dict, List -from examples import google_style -from mkapi.core import signature from mkapi.core.attribute import get_attributes, get_description +from mkapi.examples.styles import google +from mkapi.inspect import signature class A: @@ -13,7 +11,7 @@ def __init__(self): #: list of str: Doc comment *before* attribute, with type specified. self.y = ["123", "abc"] self.a = "dummy" - self.z: typing.Tuple[List[int], Dict[str, List[float]]] = ( + self.z: tuple[list[int], dict[str, list[float]]] = ( [1, 2, 3], {"a": [1.2]}, ) @@ -25,18 +23,18 @@ def __init__(self): def test_class_attribute(): attrs = get_attributes(A) assert attrs - for k, (name, (type, markdown)) in enumerate(attrs.items()): + for k, (name, (type_, markdown)) in enumerate(attrs.items()): assert name == ["x", "y", "a", "z"][k] assert markdown.startswith(["Doc ", "list of", "", "Docstring *after*"][k]) assert markdown.endswith(["attribute.", "specified.", "", "supported."][k]) if k == 0: - assert type is int + assert type_ is int elif k == 1: - assert type is None + assert type_ is None elif k == 2: assert not markdown elif k == 3: - x = signature.to_string(type) + x = signature.to_string(type_) assert x == "(list of int, dict(str: list of float))" @@ -48,7 +46,7 @@ def __init__(self): def test_class_attribute_without_desc(): attrs = get_attributes(B) - for k, (name, (type, markdown)) in enumerate(attrs.items()): + for k, (name, (_, markdown)) in enumerate(attrs.items()): assert name == ["x", "y"][k] assert markdown == "" @@ -67,45 +65,45 @@ class C: def test_dataclass_attribute(): attrs = get_attributes(C) - for k, (name, (type, markdown)) in enumerate(attrs.items()): + for k, (name, (type_, markdown)) in enumerate(attrs.items()): assert name == ["x", "y", "z"][k] assert markdown == ["int", "A", "B\n\nend."][k] if k == 0: - assert type is int + assert type_ is int @dataclass class D: x: int - y: List[str] + y: list[str] def test_dataclass_attribute_without_desc(): attrs = get_attributes(D) - for k, (name, (type, markdown)) in enumerate(attrs.items()): + for k, (name, (type_, markdown)) in enumerate(attrs.items()): assert name == ["x", "y"][k] assert markdown == "" if k == 0: assert type is int elif k == 1: - x = signature.to_string(type) + x = signature.to_string(type_) assert x == "list of str" def test_module_attribute(): - attrs = get_attributes(google_style) - for k, (name, (type, markdown)) in enumerate(attrs.items()): + attrs = get_attributes(google) + for k, (name, (type_, markdown)) in enumerate(attrs.items()): if k == 0: assert name == "first_attribute" - assert type is int + assert type_ is int assert markdown.startswith("The first module level attribute.") if k == 1: assert name == "second_attribute" - assert type is None + assert type_ is None assert markdown.startswith("str: The second module level attribute.") if k == 2: assert name == "third_attribute" - assert signature.to_string(type) == "list of int" + assert signature.to_string(type_) == "list of int" assert markdown.startswith("The third module level attribute.") assert markdown.endswith("supported.") diff --git a/tests/core/test_core_node.py b/tests/core/test_core_node.py index b9b058f4..ed7ac75c 100644 --- a/tests/core/test_core_node.py +++ b/tests/core/test_core_node.py @@ -34,8 +34,6 @@ def func(): pass class B(A): - pass - def _private(): pass @@ -146,7 +144,7 @@ def test_set_html_and_render_bases(): def test_decorated_member(): from mkapi.core import attribute - from mkapi.core.signature import Signature + from mkapi.inspect.signature import Signature node = get_node(attribute) assert node.members[-1].object.kind == "function" diff --git a/tests/core/test_core_signature.py b/tests/core/test_core_signature.py deleted file mode 100644 index ffa00921..00000000 --- a/tests/core/test_core_signature.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Callable, Dict, List, Tuple - -from mkapi.core.signature import Signature, a_of_b, get_signature, to_string - - -def test_function(add): - s = Signature(add) - assert str(s) == "(x, y=1)" - - assert "x" in s - assert s.parameters["x"].to_tuple()[1] == "int" - assert s.parameters["y"].to_tuple()[1] == "int, optional" - assert s.returns == "int" - - -def test_generator(gen): - s = Signature(gen) - assert "n" in s - assert s.parameters["n"].to_tuple()[1] == "" - assert s.yields == "str" - - -def test_class(ExampleClass): - s = Signature(ExampleClass) - assert s.parameters["x"].to_tuple()[1] == "list of int" - assert s.parameters["y"].to_tuple()[1] == "(str, int)" - - -def test_dataclass(ExampleDataClass): - s = Signature(ExampleDataClass) - assert s.attributes["x"].to_tuple()[1] == "int" - assert s.attributes["y"].to_tuple()[1] == "int" - - -def test_to_string(): - assert to_string(List) == "list" - assert to_string(Tuple) == "tuple" - assert to_string(Dict) == "dict" - - -def test_a_of_b(): - assert a_of_b(List) == "list" - assert a_of_b(List[List]) == "list of list" - assert a_of_b(List[Dict]) == "list of dict" - - to_string(Callable[[int, int], int]) - - -def test_var(): - def func(x, *args, **kwargs): - pass - - s = get_signature(func) - assert s.parameters.items[1].name == "*args" - assert s.parameters.items[2].name == "**kwargs" diff --git a/tests/core/test_mro.py b/tests/core/test_mro.py index a1251499..49bf57fb 100644 --- a/tests/core/test_mro.py +++ b/tests/core/test_mro.py @@ -1,9 +1,9 @@ -import examples -from examples.meta import C, F from mkapi.core.base import Docstring from mkapi.core.docstring import parse_bases from mkapi.core.inherit import inherit from mkapi.core.node import get_node +from mkapi.examples import meta +from mkapi.examples.meta import C, F def test_mro_docstring(): @@ -35,5 +35,5 @@ def test_mro_inherit(): def test_mro_module(): - node = get_node(examples.meta) + node = get_node(meta) assert len(node.members) == 6 diff --git a/tests/inspect/test_inspect_annotation.py b/tests/inspect/test_inspect_annotation.py new file mode 100644 index 00000000..51bbc949 --- /dev/null +++ b/tests/inspect/test_inspect_annotation.py @@ -0,0 +1,12 @@ +import inspect + +import pytest + +empty = inspect.Parameter.empty + + +def test_inspect_func_standard(): + print(type(int) is type) + print(type(list[int]) is type) + print(type(inspect.Parameter) is type) + pytest.fail("debug") diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py new file mode 100644 index 00000000..1a94eb86 --- /dev/null +++ b/tests/inspect/test_inspect_signature.py @@ -0,0 +1,89 @@ +import inspect +from collections.abc import Callable + +import pytest + +from mkapi.inspect.signature import ( + Signature, + a_of_b, + get_parameters, + get_signature, + to_string, +) + + +def test_get_parameters_error(): + with pytest.raises(TypeError): + get_parameters(1) + + +def test_get_parameters_function(add, gen): + # parameters, defaults = get_parameters(add) + # assert len(parameters) == 2 + # x, y = parameters + # assert x.name == "x" + # assert x.type.name == "int" + # assert y.name == "y" + # assert y.type.name == "int, optional" + # assert defaults["x"] is inspect.Parameter.empty + # assert defaults["y"] == "1" + signature = inspect.signature(gen) + assert signature.parameters["n"].annotation == inspect.Parameter.empty + signature = inspect.signature(add) + for x in dir(signature.parameters["x"].annotation): + print(x) + pytest.fail("debug") + + +def test_function(add): + s = Signature(add) + assert str(s) == "(x, y=1)" + + assert "x" in s + assert s.parameters["x"].to_tuple()[1] == "int" + assert s.parameters["y"].to_tuple()[1] == "int, optional" + assert s.returns == "int" + + +def test_generator(gen): + s = Signature(gen) + assert "n" in s + assert s.parameters["n"].to_tuple()[1] == "" + assert s.yields == "str" + + +def test_class(ExampleClass): # noqa: N803 + s = Signature(ExampleClass) + # assert s.parameters["x"].to_tuple()[1] == "list of int" + # assert s.parameters["y"].to_tuple()[1] == "(str, int)" + print(s.parameters["x"]) + assert 0 + + +def test_dataclass(ExampleDataClass): # noqa: N803 + s = Signature(ExampleDataClass) + assert s.attributes["x"].to_tuple()[1] == "int" + assert s.attributes["y"].to_tuple()[1] == "int" + + +def test_to_string(): + assert to_string(list) == "list" + assert to_string(tuple) == "tuple" + assert to_string(dict) == "dict" + + +def test_a_of_b(): + assert a_of_b(list) == "list" + assert a_of_b(list[list]) == "list of list" + assert a_of_b(list[dict]) == "list of dict" + + to_string(Callable[[int, int], int]) + + +def test_var(): + def func(x, *args, **kwargs): + return x, args, kwargs + + s = get_signature(func) + assert s.parameters.items[1].name == "*args" + assert s.parameters.items[2].name == "**kwargs" diff --git a/tests/library/test_types.py b/tests/library/test_types.py new file mode 100644 index 00000000..9ab5a961 --- /dev/null +++ b/tests/library/test_types.py @@ -0,0 +1,7 @@ +from collections.abc import Callable +from types import GenericAlias + + +def test_callable(): + t = Callable[[], None] + assert isinstance(t, GenericAlias) From a224697f91cc5ccc8ff9ea1597592a9f191a94cb Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 24 Dec 2023 22:26:59 +0900 Subject: [PATCH 003/148] typing.py --- src/mkapi/core/linker.py | 20 ++- src/mkapi/inspect/annotation.py | 231 -------------------------- src/mkapi/inspect/typing.py | 278 ++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 242 deletions(-) delete mode 100644 src/mkapi/inspect/annotation.py create mode 100644 src/mkapi/inspect/typing.py diff --git a/src/mkapi/core/linker.py b/src/mkapi/core/linker.py index f89e6ebf..bd5631a1 100644 --- a/src/mkapi/core/linker.py +++ b/src/mkapi/core/linker.py @@ -11,20 +11,20 @@ REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") -def link(name: str, ref: str) -> str: +def link(name: str, href: str) -> str: """Return Markdown link with a mark that indicates this link was created by MkAPI. Args: ---- name: Link name. - ref: Reference. + href: Reference. Examples: -------- >>> link('abc', 'xyz') '[abc](!xyz)' """ - return f"[{name}](!{ref})" + return f"[{name}](!{href})" def get_link(obj: type, *, include_module: bool = False) -> str: @@ -49,11 +49,9 @@ def get_link(obj: type, *, include_module: bool = False) -> str: elif hasattr(obj, "__name__"): name = obj.__name__ else: - return "" - if not hasattr(obj, "__module__"): - return name - module = obj.__module__ - if module == "builtins": + msg = f"obj has no name: {obj}" + raise ValueError(msg) + if not hasattr(obj, "__module__") or (module := obj.__module__) == "builtins": return name fullname = f"{module}.{name}" text = fullname if include_module else name @@ -69,7 +67,7 @@ def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> ---- markdown: Markdown source. abs_src_path: Absolute source path of Markdown. - abs_api_paths: A list of API paths. + abs_api_paths: List of API paths. Examples: -------- @@ -90,7 +88,7 @@ def replace(match: re.Match) -> str: else: from_mkapi = False - href = resolve_href(href, abs_src_path, abs_api_paths) + href = _resolve_href(href, abs_src_path, abs_api_paths) if href: return f"[{name}]({href})" if from_mkapi: @@ -100,7 +98,7 @@ def replace(match: re.Match) -> str: return re.sub(LINK_PATTERN, replace, markdown) -def resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: +def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: if not name: return "" abs_api_path = _match_last(name, abs_api_paths) diff --git a/src/mkapi/inspect/annotation.py b/src/mkapi/inspect/annotation.py deleted file mode 100644 index c6fa0190..00000000 --- a/src/mkapi/inspect/annotation.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Annotation string.""" -import inspect - -from mkapi.core import linker - - -def to_string( - annotation, # noqa: ANN001 - kind: str = "returns", - obj: object = None, -) -> str | type[inspect._empty]: - """Return string expression of annotation. - - If possible, type string includes link. - - Args: - ---- - annotation: Annotation - kind: 'returns' or 'yields' - - Examples: - -------- - >>> from typing import Callable, Iterator, List - >>> to_string(Iterator[str]) - 'iterator of str' - >>> to_string(Iterator[str], 'yields') - 'str' - >>> to_string(Callable) - 'callable' - >>> to_string(Callable[[int, float], str]) - 'callable(int, float: str)' - >>> from mkapi.core.node import Node - >>> to_string(List[Node]) - 'list of [Node](!mkapi.core.node.Node)' - """ - empty = inspect.Parameter.empty - if annotation is empty: - return empty - if kind == "yields": - if hasattr(annotation, "__args__") and annotation.__args__: - if len(annotation.__args__) == 1: - return to_string(annotation.__args__[0], obj=obj) - return to_string(annotation, obj=obj) - return empty - - if annotation == ...: - return "..." - if hasattr(annotation, "__forward_arg__"): - return resolve_forward_ref(obj, annotation.__forward_arg__) - if annotation == inspect.Parameter.empty or annotation is None: - return "" - name = linker.get_link(annotation) - if name: - return name - if not hasattr(annotation, "__origin__"): - return str(annotation).replace("typing.", "").lower() - origin = annotation.__origin__ - if origin is Union: - return union(annotation, obj=obj) - if origin is tuple: - args = [to_string(x, obj=obj) for x in annotation.__args__] - if args: - return "(" + ", ".join(args) + ")" - else: - return "tuple" - if origin is dict: - if type(annotation.__args__[0]) == TypeVar: - return "dict" - args = [to_string(x, obj=obj) for x in annotation.__args__] - if args: - return "dict(" + ": ".join(args) + ")" - else: - return "dict" - if not hasattr(annotation, "__args__"): - return "" - if len(annotation.__args__) == 0: - return annotation.__origin__.__name__.lower() - if len(annotation.__args__) == 1: - return a_of_b(annotation, obj=obj) - else: - return to_string_args(annotation, obj=obj) - - -# def a_of_b(annotation, obj=None) -> str: -# """Return "A of B" style string. - -# Args: -# ---- -# annotation: Annotation - -# Examples: -# -------- -# >>> from typing import List, Iterable, Iterator -# >>> a_of_b(List[str]) -# 'list of str' -# >>> a_of_b(List[List[str]]) -# 'list of list of str' -# >>> a_of_b(Iterable[int]) -# 'iterable of int' -# >>> a_of_b(Iterator[float]) -# 'iterator of float' -# """ -# origin = annotation.__origin__ -# if not hasattr(origin, "__name__"): -# return "" -# name = origin.__name__.lower() -# if type(annotation.__args__[0]) == TypeVar: -# return name -# type_ = f"{name} of " + to_string(annotation.__args__[0], obj=obj) -# if type_.endswith(" of T"): -# return name -# return type_ - - -# def union(annotation, obj=None) -> str: -# """Return a string for union annotation. - -# Args: -# ---- -# annotation: Annotation - -# Examples: -# -------- -# >>> from typing import List, Optional, Tuple, Union -# >>> union(Optional[List[str]]) -# 'list of str, optional' -# >>> union(Union[str, int]) -# 'str or int' -# >>> union(Union[str, int, float]) -# 'str, int, or float' -# >>> union(Union[List[str], Tuple[int, int]]) -# 'Union(list of str, (int, int))' -# """ -# args = annotation.__args__ -# if ( -# len(args) == 2 -# and hasattr(args[1], "__name__") -# and args[1].__name__ == "NoneType" -# ): -# return to_string(args[0], obj=obj) + ", optional" -# else: -# args = [to_string(x, obj=obj) for x in args] -# if all(" " not in arg for arg in args): -# if len(args) == 2: -# return " or ".join(args) -# else: -# return ", ".join(args[:-1]) + ", or " + args[-1] -# else: -# return "Union(" + ", ".join(to_string(x, obj=obj) for x in args) + ")" - - -def to_string_args(annotation, obj=None) -> str: - """Return a string for callable and generator annotation. - - Args: - ---- - annotation: Annotation - - Examples: - -------- - >>> from typing import Callable, List, Tuple, Any - >>> from typing import Generator, AsyncGenerator - >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) - 'callable(int, list of str: (int, int))' - >>> to_string_args(Callable[[int], Any]) - 'callable(int)' - >>> to_string_args(Callable[[str], None]) - 'callable(str)' - >>> to_string_args(Callable[..., int]) - 'callable(...: int)' - >>> to_string_args(Generator[int, float, str]) - 'generator(int, float, str)' - >>> to_string_args(AsyncGenerator[int, float]) - 'asyncgenerator(int, float)' - """ - - def to_string_with_prefix(annotation, prefix: str = ",") -> str: - s = to_string(annotation, obj=obj) - if s in ["NoneType", "any"]: - return "" - return f"{prefix} {s}" - - args = annotation.__args__ - name = annotation.__origin__.__name__.lower() - if name == "callable": - *args, returns = args - args = ", ".join(to_string(x, obj=obj) for x in args) - returns = to_string_with_prefix(returns, ":") - return f"{name}({args}{returns})" - if name == "generator": - arg, sends, returns = args - arg = to_string(arg, obj=obj) - sends = to_string_with_prefix(sends) - returns = to_string_with_prefix(returns) - if not sends and returns: - sends = "," - return f"{name}({arg}{sends}{returns})" - if name == "asyncgenerator": - arg, sends = args - arg = to_string(arg, obj=obj) - sends = to_string_with_prefix(sends) - return f"{name}({arg}{sends})" - return "" - - -def resolve_forward_ref(obj: object, name: str) -> str: - """Return a resolved name for `typing.ForwardRef`. - - Args: - ---- - obj: Object - name: Forward reference name. - - Examples: - -------- - >>> from mkapi.core.base import Docstring - >>> resolve_forward_ref(Docstring, 'Docstring') - '[Docstring](!mkapi.core.base.Docstring)' - >>> resolve_forward_ref(Docstring, 'invalid_object_name') - 'invalid_object_name' - """ - if obj is None or not hasattr(obj, "__module__"): - return name - module = importlib.import_module(obj.__module__) - globals = dict(inspect.getmembers(module)) - try: - type = eval(name, globals) - except NameError: - return name - else: - return to_string(type) diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py new file mode 100644 index 00000000..898273df --- /dev/null +++ b/src/mkapi/inspect/typing.py @@ -0,0 +1,278 @@ +"""Annotation string.""" +import importlib +import inspect +from types import UnionType +from typing import ForwardRef, Union, get_args, get_origin + +from mkapi.core import linker + + +def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001, PLR0911 + """Return string expression for type. + + If possible, type string includes link. + + Args: + ---- + tp: type + kind: 'returns' or 'yields' + obj: Object + + Examples: + -------- + >>> to_string(str) + 'str' + >>> from mkapi.core.node import Node + >>> to_string(Node) + '[Node](!mkapi.core.node.Node)' + >>> to_string(...) + '...' + >>> to_string([int, str]) + '[int, str]' + >>> to_string(int | str) + 'int | str' + """ + if kind == "yields": + return _to_string_for_yields(tp, obj) + if tp == ...: + return "..." + if isinstance(tp, list): + args = ", ".join(to_string(arg, obj=obj) for arg in tp) + return f"[{args}]" + if isinstance(tp, UnionType): + return " | ".join(to_string(arg, obj=obj) for arg in get_args(tp)) + if isinstance(tp, str): + return resolve_forward_ref(tp, obj) + if isinstance(tp, ForwardRef): + return resolve_forward_ref(tp.__forward_arg__, obj) + if isinstance(tp, type): + return linker.get_link(tp) + if get_origin(tp): + return resolve_orign_args(tp, obj) + raise NotImplementedError + # if not hasattr(annotation, "__origin__"): + # return str(annotation).replace("typing.", "").lower() + # origin = annotation.__origin__ + # if origin is Union: + # return union(annotation, obj=obj) + # if origin is tuple: + # args = [to_string(x, obj=obj) for x in annotation.__args__] + # if args: + # return "(" + ", ".join(args) + ")" + # else: + # return "tuple" + # if origin is dict: + # if type(annotation.__args__[0]) == TypeVar: + # return "dict" + # args = [to_string(x, obj=obj) for x in annotation.__args__] + # if args: + # return "dict(" + ": ".join(args) + ")" + # else: + # return "dict" + # if not hasattr(annotation, "__args__"): + # return "" + # if len(annotation.__args__) == 0: + # return annotation.__origin__.__name__.lower() + # if len(annotation.__args__) == 1: + # return a_of_b(annotation, obj=obj) + # else: + # return to_string_args(annotation, obj=obj) + + +def resolve_forward_ref(name: str, obj: object) -> str: + """Return a resolved name for `str` or `typing.ForwardRef`. + + Args: + ---- + name: Forward reference name. + obj: Object + + Examples: + -------- + >>> from mkapi.core.base import Docstring + >>> resolve_forward_ref('Docstring', Docstring) + '[Docstring](!mkapi.core.base.Docstring)' + >>> resolve_forward_ref('invalid_object_name', Docstring) + 'invalid_object_name' + """ + if obj is None or not hasattr(obj, "__module__"): + return name + module = importlib.import_module(obj.__module__) + globals_ = dict(inspect.getmembers(module)) + try: + type_ = eval(name, globals_) + except NameError: + return name + else: + return to_string(type_) + + +def resolve_orign_args(tp, obj: object = None) -> str: # noqa: ANN001 + """Return string expression for X[Y, Z, ...]. + + Args: + ---- + tp: type + obj: Object + + Examples: + -------- + >>> resolve_orign_args(list[str]) + 'list[str]' + >>> from typing import List, Tuple + >>> resolve_orign_args(List[Tuple[int, str]]) + 'list[tuple[int, str]]' + >>> from mkapi.core.node import Node + >>> resolve_orign_args(list[Node]) + 'list[[Node](!mkapi.core.node.Node)]' + >>> from collections.abc import Callable, Iterator + >>> resolve_orign_args(Iterator[float]) + '[Iterator](!collections.abc.Iterator)[float]' + >>> resolve_orign_args(Callable[[], str]) + '[Callable](!collections.abc.Callable)[[], str]' + >>> from typing import Union, Optional + >>> resolve_orign_args(Union[int, str]) + 'int | str' + >>> resolve_orign_args(Optional[bool]) + 'bool | None' + + """ + origin, args = get_origin(tp), get_args(tp) + args_list = [to_string(arg, obj=obj) for arg in args] + if origin is Union: + if len(args) == 2 and args[1] == type(None): # noqa: PLR2004 + return f"{args_list[0]} | None" + return " | ".join(args_list) + origin_str = to_string(origin, obj=obj) + args_str = ", ".join(args_list) + return f"{origin_str}[{args_str}]" + + +def _to_string_for_yields(annotation, obj: object) -> str: # noqa: ANN001 + if hasattr(annotation, "__args__") and annotation.__args__: + if len(annotation.__args__) == 1: + return to_string(annotation.__args__[0], obj=obj) + return to_string(annotation, obj=obj) + raise ValueError + + +# def a_of_b(annotation, obj=None) -> str: +# """Return "A of B" style string. + +# Args: +# ---- +# annotation: Annotation + +# Examples: +# -------- +# >>> from typing import List, Iterable, Iterator +# >>> a_of_b(List[str]) +# 'list of str' +# >>> a_of_b(List[List[str]]) +# 'list of list of str' +# >>> a_of_b(Iterable[int]) +# 'iterable of int' +# >>> a_of_b(Iterator[float]) +# 'iterator of float' +# """ +# origin = annotation.__origin__ +# if not hasattr(origin, "__name__"): +# return "" +# name = origin.__name__.lower() +# if type(annotation.__args__[0]) == TypeVar: +# return name +# type_ = f"{name} of " + to_string(annotation.__args__[0], obj=obj) +# if type_.endswith(" of T"): +# return name +# return type_ + + +# def union(annotation, obj=None) -> str: +# """Return a string for union annotation. + +# Args: +# ---- +# annotation: Annotation + +# Examples: +# -------- +# >>> from typing import List, Optional, Tuple, Union +# >>> union(Optional[List[str]]) +# 'list of str, optional' +# >>> union(Union[str, int]) +# 'str or int' +# >>> union(Union[str, int, float]) +# 'str, int, or float' +# >>> union(Union[List[str], Tuple[int, int]]) +# 'Union(list of str, (int, int))' +# """ +# args = annotation.__args__ +# if ( +# len(args) == 2 +# and hasattr(args[1], "__name__") +# and args[1].__name__ == "NoneType" +# ): +# return to_string(args[0], obj=obj) + ", optional" +# else: +# args = [to_string(x, obj=obj) for x in args] +# if all(" " not in arg for arg in args): +# if len(args) == 2: +# return " or ".join(args) +# else: +# return ", ".join(args[:-1]) + ", or " + args[-1] +# else: +# return "Union(" + ", ".join(to_string(x, obj=obj) for x in args) + ")" + + +# def to_string_args(annotation, obj: object = None) -> str: +# """Return a string for callable and generator annotation. + +# Args: +# ---- +# annotation: Annotation + +# Examples: +# -------- +# >>> from typing import Callable, List, Tuple, Any +# >>> from typing import Generator, AsyncGenerator +# >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) +# 'callable(int, list of str: (int, int))' +# >>> to_string_args(Callable[[int], Any]) +# 'callable(int)' +# >>> to_string_args(Callable[[str], None]) +# 'callable(str)' +# >>> to_string_args(Callable[..., int]) +# 'callable(...: int)' +# >>> to_string_args(Generator[int, float, str]) +# 'generator(int, float, str)' +# >>> to_string_args(AsyncGenerator[int, float]) +# 'asyncgenerator(int, float)' +# """ + +# def to_string_with_prefix(annotation, prefix: str = ",") -> str: +# s = to_string(annotation, obj=obj) +# if s in ["NoneType", "any"]: +# return "" +# return f"{prefix} {s}" + +# args = annotation.__args__ +# name = annotation.__origin__.__name__.lower() +# if name == "callable": +# *args, returns = args +# args = ", ".join(to_string(x, obj=obj) for x in args) +# returns = to_string_with_prefix(returns, ":") +# return f"{name}({args}{returns})" +# if name == "generator": +# arg, sends, returns = args +# arg = to_string(arg, obj=obj) +# sends = to_string_with_prefix(sends) +# returns = to_string_with_prefix(returns) +# if not sends and returns: +# sends = "," +# return f"{name}({arg}{sends}{returns})" +# if name == "asyncgenerator": +# arg, sends = args +# arg = to_string(arg, obj=obj) +# sends = to_string_with_prefix(sends) +# return f"{name}({arg}{sends})" +# return "" From 7e2ee7471e67be2b33441451c73b93571681e40d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 24 Dec 2023 23:17:29 +0900 Subject: [PATCH 004/148] signature.py --- pyproject.toml | 18 ++++- src/mkapi/inspect/signature.py | 93 ++++++++---------------- src/mkapi/inspect/typing.py | 22 +++--- tests/inspect/test_inspect_annotation.py | 12 --- tests/inspect/test_inspect_signature.py | 41 +++-------- tests/inspect/test_inspect_typing.py | 0 tests/library/test_types.py | 7 -- 7 files changed, 67 insertions(+), 126 deletions(-) delete mode 100644 tests/inspect/test_inspect_annotation.py create mode 100644 tests/inspect/test_inspect_typing.py delete mode 100644 tests/library/test_types.py diff --git a/pyproject.toml b/pyproject.toml index 0de665eb..12eeacc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,21 +76,31 @@ deploy = "mkdocs gh-deploy --force" target-version = "py311" line-length = 88 select = ["ALL"] -ignore = ["ANN101", "ANN102", "ANN002", "ANN003", "ERA001", "N812"] +ignore = [ + "ANN002", + "ANN003", + "ANN101", + "ANN102", + "D105", + "D406", + "D407", + "ERA001", + "N812", +] [tool.ruff.extend-per-file-ignores] "tests/*.py" = ["ANN", "D", "S101", "INP001", "T201", "PLR2004", "PGH003"] "src/mkapi/examples/*.py" = [ "ANN", + "ARG001", "D", + "E741", "ERA001", "INP001", + "PLR0913", "PLR2004", "S101", "T201", - "ARG001", - "PLR0913", - "E741", ] [tool.ruff.lint] diff --git a/src/mkapi/inspect/signature.py b/src/mkapi/inspect/signature.py index f0a5c82f..41cebf9e 100644 --- a/src/mkapi/inspect/signature.py +++ b/src/mkapi/inspect/signature.py @@ -7,6 +7,7 @@ from mkapi.core import preprocess from mkapi.core.attribute import get_attributes from mkapi.core.base import Inline, Item, Section, Type +from mkapi.inspect.typing import to_string @dataclass @@ -14,11 +15,9 @@ class Signature: """Signature class. Args: - ---- obj: Object Attributes: - ---------- signature: `inspect.Signature` instance. parameters: Parameters section. defaults: Default value dictionary. Key is parameter name and @@ -36,7 +35,7 @@ class Signature: returns: str = field(default="", init=False) yields: str = field(default="", init=False) - def __post_init__(self): + def __post_init__(self) -> None: if self.obj is None: return try: @@ -45,50 +44,22 @@ def __post_init__(self): self.set_attributes() return - items = [] - for name, parameter in self.signature.parameters.items(): - if name == "self": - continue - if parameter.kind is inspect.Parameter.VAR_POSITIONAL: - name = "*" + name - elif parameter.kind is inspect.Parameter.VAR_KEYWORD: - name = "**" + name - if isinstance(parameter.annotation, str): - type = resolve_forward_ref(self.obj, parameter.annotation) - else: - type = to_string(parameter.annotation, obj=self.obj) - default = parameter.default - if default == inspect.Parameter.empty: - self.defaults[name] = default - else: - self.defaults[name] = f"{default!r}" - if not type: - type = "optional" - elif not type.endswith(", optional"): - type += ", optional" - items.append(Item(name, Type(type))) + items, self.defaults = get_parameters(self.obj) self.parameters = Section("Parameters", items=items) self.set_attributes() - return_annotation = self.signature.return_annotation - - if isinstance(return_annotation, str): - self.returns = resolve_forward_ref(self.obj, return_annotation) - else: - self.returns = to_string(return_annotation, "returns", obj=self.obj) - self.yields = to_string(return_annotation, "yields", obj=self.obj) + return_type = self.signature.return_annotation + self.returns = to_string(return_type, kind="returns", obj=self.obj) + self.yields = to_string(return_type, kind="yields", obj=self.obj) - def __contains__(self, name) -> bool: + def __contains__(self, name: str) -> bool: return name in self.parameters - def __getitem__(self, name): + def __getitem__(self, name: str): # noqa: ANN204 return getattr(self, name.lower()) - def __str__(self): + def __str__(self) -> str: args = self.arguments - if args is None: - return "" - else: - return "(" + ", ".join(args) + ")" + return "" if args is None else "(" + ", ".join(args) + ")" @property def arguments(self) -> list[str] | None: @@ -104,25 +75,24 @@ def arguments(self) -> list[str] | None: args.append(arg) return args - def set_attributes(self): - """Examples - >>> from mkapi.core.base import Base - >>> s = Signature(Base) - >>> s.parameters['name'].to_tuple() - ('name', 'str, optional', 'Name of self.') - >>> s.attributes['html'].to_tuple() - ('html', 'str', 'HTML output after conversion.') + def set_attributes(self) -> None: + """Set attributes. + + Examples: + >>> from mkapi.core.base import Base + >>> s = Signature(Base) + >>> s.parameters['name'].to_tuple() + ('name', 'str, optional', 'Name of self.') + >>> s.attributes['html'].to_tuple() + ('html', 'str', 'HTML output after conversion.') """ items = [] - for name, (type, description) in get_attributes(self.obj).items(): - if isinstance(type, str) and type: - type = resolve_forward_ref(self.obj, type) - else: - type = to_string(type, obj=self.obj) if type else "" - if not type: - type, description = preprocess.split_type(description) + for name, (tp, description) in get_attributes(self.obj).items(): + type_str = to_string(tp, obj=self.obj) if tp else "" + if not type_str: + type_str, description = preprocess.split_type(description) # noqa: PLW2901 - item = Item(name, Type(type), Inline(description)) + item = Item(name, Type(type_str), Inline(description)) if is_dataclass(self.obj): if name in self.parameters: self.parameters[name].set_description(item.description) @@ -132,7 +102,8 @@ def set_attributes(self): items.append(item) self.attributes = Section("Attributes", items=items) - def split(self, sep=","): + def split(self, sep: str = ",") -> list[str]: + """Return a list of substring.""" return str(self).split(sep) @@ -151,12 +122,8 @@ def get_parameters(obj) -> tuple[list[Item], dict[str, Any]]: # noqa: ANN001 key = f"**{name}" else: key = name - if isinstance(parameter.annotation, str): - type_ = resolve_forward_ref(obj, parameter.annotation) - else: - type_ = to_string(parameter.annotation, obj=obj) - default = parameter.default - if default == inspect.Parameter.empty: + type_ = to_string(parameter.annotation, obj=obj) + if (default := parameter.default) == inspect.Parameter.empty: defaults[key] = default else: defaults[key] = f"{default!r}" @@ -164,7 +131,7 @@ def get_parameters(obj) -> tuple[list[Item], dict[str, Any]]: # noqa: ANN001 type_ = "optional" elif not type_.endswith(", optional"): type_ += ", optional" - items.append(Item(name, Type(type_))) + items.append(Item(key, Type(type_))) return items, defaults diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index 898273df..960e0152 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -1,10 +1,10 @@ -"""Annotation string.""" +"""Type string.""" import importlib import inspect from types import UnionType from typing import ForwardRef, Union, get_args, get_origin -from mkapi.core import linker +from mkapi.core.linker import get_link def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001, PLR0911 @@ -13,13 +13,11 @@ def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: If possible, type string includes link. Args: - ---- tp: type kind: 'returns' or 'yields' obj: Object Examples: - -------- >>> to_string(str) 'str' >>> from mkapi.core.node import Node @@ -34,6 +32,8 @@ def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: """ if kind == "yields": return _to_string_for_yields(tp, obj) + if tp is None: + return "" if tp == ...: return "..." if isinstance(tp, list): @@ -46,7 +46,7 @@ def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: if isinstance(tp, ForwardRef): return resolve_forward_ref(tp.__forward_arg__, obj) if isinstance(tp, type): - return linker.get_link(tp) + return get_link(tp) if get_origin(tp): return resolve_orign_args(tp, obj) raise NotImplementedError @@ -148,12 +148,12 @@ def resolve_orign_args(tp, obj: object = None) -> str: # noqa: ANN001 return f"{origin_str}[{args_str}]" -def _to_string_for_yields(annotation, obj: object) -> str: # noqa: ANN001 - if hasattr(annotation, "__args__") and annotation.__args__: - if len(annotation.__args__) == 1: - return to_string(annotation.__args__[0], obj=obj) - return to_string(annotation, obj=obj) - raise ValueError +def _to_string_for_yields(tp, obj: object) -> str: # noqa: ANN001 + if hasattr(tp, "__args__") and tp.__args__: + if len(tp.__args__) == 1: + return to_string(tp.__args__[0], obj=obj) + return to_string(tp, obj=obj) + return "" # def a_of_b(annotation, obj=None) -> str: diff --git a/tests/inspect/test_inspect_annotation.py b/tests/inspect/test_inspect_annotation.py deleted file mode 100644 index 51bbc949..00000000 --- a/tests/inspect/test_inspect_annotation.py +++ /dev/null @@ -1,12 +0,0 @@ -import inspect - -import pytest - -empty = inspect.Parameter.empty - - -def test_inspect_func_standard(): - print(type(int) is type) - print(type(list[int]) is type) - print(type(inspect.Parameter) is type) - pytest.fail("debug") diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py index 1a94eb86..7352a93d 100644 --- a/tests/inspect/test_inspect_signature.py +++ b/tests/inspect/test_inspect_signature.py @@ -5,7 +5,6 @@ from mkapi.inspect.signature import ( Signature, - a_of_b, get_parameters, get_signature, to_string, @@ -17,22 +16,16 @@ def test_get_parameters_error(): get_parameters(1) -def test_get_parameters_function(add, gen): - # parameters, defaults = get_parameters(add) - # assert len(parameters) == 2 - # x, y = parameters - # assert x.name == "x" - # assert x.type.name == "int" - # assert y.name == "y" - # assert y.type.name == "int, optional" - # assert defaults["x"] is inspect.Parameter.empty - # assert defaults["y"] == "1" - signature = inspect.signature(gen) - assert signature.parameters["n"].annotation == inspect.Parameter.empty - signature = inspect.signature(add) - for x in dir(signature.parameters["x"].annotation): - print(x) - pytest.fail("debug") +def test_get_parameters_function(add): + parameters, defaults = get_parameters(add) + assert len(parameters) == 2 + x, y = parameters + assert x.name == "x" + assert x.type.name == "int" + assert y.name == "y" + assert y.type.name == "int, optional" + assert defaults["x"] is inspect.Parameter.empty + assert defaults["y"] == "1" def test_function(add): @@ -48,16 +41,14 @@ def test_function(add): def test_generator(gen): s = Signature(gen) assert "n" in s - assert s.parameters["n"].to_tuple()[1] == "" + # assert s.parameters["n"].to_tuple()[1] == "" assert s.yields == "str" def test_class(ExampleClass): # noqa: N803 s = Signature(ExampleClass) - # assert s.parameters["x"].to_tuple()[1] == "list of int" + assert s.parameters["x"].to_tuple()[1] == "list[int]" # assert s.parameters["y"].to_tuple()[1] == "(str, int)" - print(s.parameters["x"]) - assert 0 def test_dataclass(ExampleDataClass): # noqa: N803 @@ -72,14 +63,6 @@ def test_to_string(): assert to_string(dict) == "dict" -def test_a_of_b(): - assert a_of_b(list) == "list" - assert a_of_b(list[list]) == "list of list" - assert a_of_b(list[dict]) == "list of dict" - - to_string(Callable[[int, int], int]) - - def test_var(): def func(x, *args, **kwargs): return x, args, kwargs diff --git a/tests/inspect/test_inspect_typing.py b/tests/inspect/test_inspect_typing.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/library/test_types.py b/tests/library/test_types.py deleted file mode 100644 index 9ab5a961..00000000 --- a/tests/library/test_types.py +++ /dev/null @@ -1,7 +0,0 @@ -from collections.abc import Callable -from types import GenericAlias - - -def test_callable(): - t = Callable[[], None] - assert isinstance(t, GenericAlias) From ee9bee2dc5bc071c84d951740bc614965d9c40aa Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 25 Dec 2023 14:43:22 +0900 Subject: [PATCH 005/148] base.py --- {src/mkapi/examples => examples}/__init__.py | 0 .../examples => examples}/cls/__init__.py | 0 {src/mkapi/examples => examples}/cls/abc.py | 0 .../examples => examples}/cls/decorated.py | 2 +- .../examples => examples}/cls/decorator.py | 0 .../examples => examples}/cls/inherit.py | 0 .../cls/member_order_base.py | 0 examples/cls/member_order_sub.py | 11 + .../mkapi/examples => examples}/cls/method.py | 0 {src/mkapi/examples => examples}/custom.py | 0 {src/mkapi/examples => examples}/filter.py | 0 {src/mkapi/examples => examples}/inherit.py | 0 .../examples => examples}/inherit_comment.py | 0 {src/mkapi/examples => examples}/inspect.py | 0 .../examples => examples}/link/__init__.py | 0 .../examples => examples}/link/fullname.py | 0 .../examples => examples}/link/qualname.py | 0 {src/mkapi/examples => examples}/meta.py | 0 .../examples => examples}/styles/__init__.py | 0 .../examples => examples}/styles/google.py | 0 .../examples => examples}/styles/numpy.py | 0 pyproject.toml | 4 +- src/mkapi/core/base.py | 277 ++++++++---------- src/mkapi/examples/cls/member_order_sub.py | 11 - tests/conftest.py | 2 +- tests/core/test_core_attribute.py | 2 +- tests/core/test_mro.py | 4 +- 27 files changed, 141 insertions(+), 172 deletions(-) rename {src/mkapi/examples => examples}/__init__.py (100%) rename {src/mkapi/examples => examples}/cls/__init__.py (100%) rename {src/mkapi/examples => examples}/cls/abc.py (100%) rename {src/mkapi/examples => examples}/cls/decorated.py (86%) rename {src/mkapi/examples => examples}/cls/decorator.py (100%) rename {src/mkapi/examples => examples}/cls/inherit.py (100%) rename {src/mkapi/examples => examples}/cls/member_order_base.py (100%) create mode 100644 examples/cls/member_order_sub.py rename {src/mkapi/examples => examples}/cls/method.py (100%) rename {src/mkapi/examples => examples}/custom.py (100%) rename {src/mkapi/examples => examples}/filter.py (100%) rename {src/mkapi/examples => examples}/inherit.py (100%) rename {src/mkapi/examples => examples}/inherit_comment.py (100%) rename {src/mkapi/examples => examples}/inspect.py (100%) rename {src/mkapi/examples => examples}/link/__init__.py (100%) rename {src/mkapi/examples => examples}/link/fullname.py (100%) rename {src/mkapi/examples => examples}/link/qualname.py (100%) rename {src/mkapi/examples => examples}/meta.py (100%) rename {src/mkapi/examples => examples}/styles/__init__.py (100%) rename {src/mkapi/examples => examples}/styles/google.py (100%) rename {src/mkapi/examples => examples}/styles/numpy.py (100%) delete mode 100644 src/mkapi/examples/cls/member_order_sub.py diff --git a/src/mkapi/examples/__init__.py b/examples/__init__.py similarity index 100% rename from src/mkapi/examples/__init__.py rename to examples/__init__.py diff --git a/src/mkapi/examples/cls/__init__.py b/examples/cls/__init__.py similarity index 100% rename from src/mkapi/examples/cls/__init__.py rename to examples/cls/__init__.py diff --git a/src/mkapi/examples/cls/abc.py b/examples/cls/abc.py similarity index 100% rename from src/mkapi/examples/cls/abc.py rename to examples/cls/abc.py diff --git a/src/mkapi/examples/cls/decorated.py b/examples/cls/decorated.py similarity index 86% rename from src/mkapi/examples/cls/decorated.py rename to examples/cls/decorated.py index 034827db..1297269b 100644 --- a/src/mkapi/examples/cls/decorated.py +++ b/examples/cls/decorated.py @@ -1,7 +1,7 @@ """Decorator examples.""" import pytest -from mkapi.examples.cls.decorator import deco_with_wraps, deco_without_wraps +from examples.cls.decorator import deco_with_wraps, deco_without_wraps @deco_without_wraps diff --git a/src/mkapi/examples/cls/decorator.py b/examples/cls/decorator.py similarity index 100% rename from src/mkapi/examples/cls/decorator.py rename to examples/cls/decorator.py diff --git a/src/mkapi/examples/cls/inherit.py b/examples/cls/inherit.py similarity index 100% rename from src/mkapi/examples/cls/inherit.py rename to examples/cls/inherit.py diff --git a/src/mkapi/examples/cls/member_order_base.py b/examples/cls/member_order_base.py similarity index 100% rename from src/mkapi/examples/cls/member_order_base.py rename to examples/cls/member_order_base.py diff --git a/examples/cls/member_order_sub.py b/examples/cls/member_order_sub.py new file mode 100644 index 00000000..15c94a8b --- /dev/null +++ b/examples/cls/member_order_sub.py @@ -0,0 +1,11 @@ +from examples.cls.member_order_base import A + + +class B: + def b(self): + """Mro index: 2, sourcefile index: 0, line number: 5.""" + + +class C(A, B): + def c(self): + """Mro index: 0, sourcefile index: 0, line number: 10.""" diff --git a/src/mkapi/examples/cls/method.py b/examples/cls/method.py similarity index 100% rename from src/mkapi/examples/cls/method.py rename to examples/cls/method.py diff --git a/src/mkapi/examples/custom.py b/examples/custom.py similarity index 100% rename from src/mkapi/examples/custom.py rename to examples/custom.py diff --git a/src/mkapi/examples/filter.py b/examples/filter.py similarity index 100% rename from src/mkapi/examples/filter.py rename to examples/filter.py diff --git a/src/mkapi/examples/inherit.py b/examples/inherit.py similarity index 100% rename from src/mkapi/examples/inherit.py rename to examples/inherit.py diff --git a/src/mkapi/examples/inherit_comment.py b/examples/inherit_comment.py similarity index 100% rename from src/mkapi/examples/inherit_comment.py rename to examples/inherit_comment.py diff --git a/src/mkapi/examples/inspect.py b/examples/inspect.py similarity index 100% rename from src/mkapi/examples/inspect.py rename to examples/inspect.py diff --git a/src/mkapi/examples/link/__init__.py b/examples/link/__init__.py similarity index 100% rename from src/mkapi/examples/link/__init__.py rename to examples/link/__init__.py diff --git a/src/mkapi/examples/link/fullname.py b/examples/link/fullname.py similarity index 100% rename from src/mkapi/examples/link/fullname.py rename to examples/link/fullname.py diff --git a/src/mkapi/examples/link/qualname.py b/examples/link/qualname.py similarity index 100% rename from src/mkapi/examples/link/qualname.py rename to examples/link/qualname.py diff --git a/src/mkapi/examples/meta.py b/examples/meta.py similarity index 100% rename from src/mkapi/examples/meta.py rename to examples/meta.py diff --git a/src/mkapi/examples/styles/__init__.py b/examples/styles/__init__.py similarity index 100% rename from src/mkapi/examples/styles/__init__.py rename to examples/styles/__init__.py diff --git a/src/mkapi/examples/styles/google.py b/examples/styles/google.py similarity index 100% rename from src/mkapi/examples/styles/google.py rename to examples/styles/google.py diff --git a/src/mkapi/examples/styles/numpy.py b/examples/styles/numpy.py similarity index 100% rename from src/mkapi/examples/styles/numpy.py rename to examples/styles/numpy.py diff --git a/pyproject.toml b/pyproject.toml index 12eeacc6..f8114417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Software Development :: Documentation", ] dynamic = ["version"] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = ["jinja2", "markdown", "mkdocs"] [project.urls] @@ -90,7 +90,7 @@ ignore = [ [tool.ruff.extend-per-file-ignores] "tests/*.py" = ["ANN", "D", "S101", "INP001", "T201", "PLR2004", "PGH003"] -"src/mkapi/examples/*.py" = [ +"examples/*.py" = [ "ANN", "ARG001", "D", diff --git a/src/mkapi/core/base.py b/src/mkapi/core/base.py index 4ef97796..2e8b954a 100644 --- a/src/mkapi/core/base.py +++ b/src/mkapi/core/base.py @@ -1,7 +1,6 @@ """A module provides entity classes to represent docstring structure.""" from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from typing import Optional from mkapi.core import preprocess from mkapi.core.linker import LINK_PATTERN @@ -11,9 +10,8 @@ class Base: """Base class. - Examples - -------- - >>> base = Base('x', 'markdown') + Examples: + >>> base = Base("x", "markdown") >>> base Base('x') >>> bool(base) @@ -30,35 +28,34 @@ class Base: name: str = "" #: Name of self. markdown: str = "" #: Markdown source. html: str = field(default="", init=False) #: HTML output after conversion. - callback: Optional[Callable[["Base"], str]] = field(default=None, init=False) + callback: Callable[["Base"], str] | None = field(default=None, init=False) """Callback function to modify HTML output.""" - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ return f"{class_name}({self.name!r})" def __bool__(self) -> bool: - """Returns True if name is not empty.""" + """Return True if name is not empty.""" return bool(self.name) def __iter__(self) -> Iterator["Base"]: - """Yields self if markdown is not empty.""" + """Yield self if markdown is not empty.""" if self.markdown: yield self - def set_html(self, html: str): - """Sets HTML output. + def set_html(self, html: str) -> None: + """Set HTML output. Args: - ---- html: HTML output. """ self.html = html if self.callback: self.html = self.callback(self) - def copy(self): - """Returns a copy of the {class} instance.""" + def copy(self): # noqa: ANN201 + """Return a copy of the {class} instance.""" return self.__class__(name=self.name, markdown=self.markdown) @@ -66,12 +63,11 @@ def copy(self): class Inline(Base): """Inline class. - Examples - -------- + Examples: >>> inline = Inline() >>> bool(inline) False - >>> inline = Inline('markdown') + >>> inline = Inline("markdown") >>> inline.name == inline.markdown True >>> inline @@ -89,31 +85,29 @@ class Inline(Base): markdown: str = field(init=False) - def __post_init__(self): + def __post_init__(self) -> None: self.markdown = self.name - def set_html(self, html: str): - """Sets `html` attribute cleaning `p` tags.""" + def set_html(self, html: str) -> None: + """Set `html` attribute cleaning `p` tags.""" html = preprocess.strip_ptags(html) super().set_html(html) - def copy(self): + def copy(self): # noqa: ANN201, D102 return self.__class__(name=self.name) @dataclass(repr=False) class Type(Inline): - """Type class represents type of Item_, Section_, Docstring_, or - [Object](mkapi.core.structure.Object). + """Type of Item_, Section_, Docstring_, or [Object](mkapi.core.structure.Object). - Examples - -------- - >>> a = Type('str') + Examples: + >>> a = Type("str") >>> a Type('str') >>> list(a) [] - >>> b = Type('[Object](base.Object)') + >>> b = Type("[Object](base.Object)") >>> b.markdown '[Object](base.Object)' >>> list(b) @@ -124,7 +118,7 @@ class Type(Inline): markdown: str = field(default="", init=False) - def __post_init__(self): + def __post_init__(self) -> None: if LINK_PATTERN.search(self.name): self.markdown = self.name else: @@ -133,8 +127,7 @@ def __post_init__(self): @dataclass class Item(Type): - """Item class represents an item in Parameters, Attributes, and Raises sections, - *etc.* + """Item in Parameters, Attributes, and Raises sections, *etc.*. Args: ---- @@ -172,14 +165,14 @@ class Item(Type): description: Inline = field(default_factory=Inline) kind: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if isinstance(self.type, str): self.type = Type(self.type) if isinstance(self.description, str): self.description = Inline(self.description) super().__post_init__() - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ return f"{class_name}({self.name!r}, {self.type.name!r})" @@ -189,50 +182,46 @@ def __iter__(self) -> Iterator[Base]: yield from self.type yield from self.description - def set_html(self, html: str): + def set_html(self, html: str) -> None: + """Set `html` attribute cleaning `strong` tags.""" html = html.replace("", "__").replace("", "__") super().set_html(html) def to_tuple(self) -> tuple[str, str, str]: - """Returns a tuple of (name, type, description). + """Return a tuple of (name, type, description). - Examples - -------- - >>> item = Item('[x](x)', 'int', 'A parameter.') + Examples: + >>> item = Item("[x](x)", "int", "A parameter.") >>> item.to_tuple() ('[x](x)', 'int', 'A parameter.') """ return self.name, self.type.name, self.description.name - def set_type(self, type: Type, force: bool = False): - """Sets type. + def set_type(self, type_: Type, *, force: bool = False) -> None: + """Set type. Args: - ---- - item: Type instance. + type_: Type instance. force: If True, overwrite self regardless of existing type and description. See Also: - -------- * Item.update_ """ if not force and self.type.name: return - if type.name: - self.type = type.copy() + if type_.name: + self.type = type_.copy() - def set_description(self, description: Inline, force: bool = False): - """Sets description. + def set_description(self, description: Inline, *, force: bool = False) -> None: + """Set description. Args: - ---- description: Inline instance. force: If True, overwrite self regardless of existing type and description. See Also: - -------- * Item.update_ """ if not force and self.description.name: @@ -240,55 +229,53 @@ def set_description(self, description: Inline, force: bool = False): if description.name: self.description = description.copy() - def update(self, item: "Item", force: bool = False): - """Updates type and description. + def update(self, item: "Item", *, force: bool = False) -> None: + """Update type and description. Args: - ---- item: Item instance. force: If True, overwrite self regardless of existing type and description. Examples: - -------- - >>> item = Item('x') - >>> item2 = Item('x', 'int', 'description') + >>> item = Item("x") + >>> item2 = Item("x", "int", "description") >>> item.update(item2) >>> item.to_tuple() ('x', 'int', 'description') - >>> item2 = Item('x', 'str', 'new description') + >>> item2 = Item("x", "str", "new description") >>> item.update(item2) >>> item.to_tuple() ('x', 'int', 'description') >>> item.update(item2, force=True) >>> item.to_tuple() ('x', 'str', 'new description') - >>> item.update(Item('x'), force=True) + >>> item.update(Item("x"), force=True) >>> item.to_tuple() ('x', 'str', 'new description') """ if item.name != self.name: - raise ValueError(f"Different name: {self.name} != {item.name}.") - self.set_description(item.description, force) - self.set_type(item.type, force) + msg = f"Different name: {self.name} != {item.name}." + raise ValueError(msg) + self.set_description(item.description, force=force) + self.set_type(item.type, force=force) - def copy(self): - return Item(*self.to_tuple(), kind=self.kind) + def copy(self): # noqa: ANN201, D102 + name, type_, desc = self.to_tuple() + return Item(name, Type(type_), Inline(desc), kind=self.kind) @dataclass class Section(Base): - """Section class represents a section in docstring. + """Section in docstring. Args: - ---- items: List for Arguments, Attributes, or Raises sections, *etc.* type: Type of self. Examples: - -------- - >>> items = [Item('x'), Item('[y](a)'), Item('z')] - >>> section = Section('Parameters', items=items) + >>> items = [Item("x"), Item("[y](a)"), Item("z")] + >>> section = Section("Parameters", items=items) >>> section Section('Parameters', num_items=3) >>> list(section) @@ -296,22 +283,22 @@ class Section(Base): """ items: list[Item] = field(default_factory=list) - type: Type = field(default_factory=Type) + type: Type = field(default_factory=Type) # noqa: A003 - def __post_init__(self): + def __post_init__(self) -> None: if self.markdown: self.markdown = preprocess.convert(self.markdown) - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ return f"{class_name}({self.name!r}, num_items={len(self.items)})" - def __bool__(self): - """Returns True if the number of items is larger than 0.""" + def __bool__(self) -> bool: + """Return True if the number of items is larger than 0.""" return len(self.items) > 0 def __iter__(self) -> Iterator[Base]: - """Yields a Base_ instance that has non empty Markdown.""" + """Yield a Base_ instance that has non empty Markdown.""" yield from self.type if self.markdown: yield self @@ -319,20 +306,18 @@ def __iter__(self) -> Iterator[Base]: yield from item def __getitem__(self, name: str) -> Item: - """Returns an Item_ instance whose name is equal to `name`. + """Return an Item_ instance whose name is equal to `name`. If there is no Item instance, a Item instance is newly created. Args: - ---- name: Item name. Examples: - -------- - >>> section = Section("", items=[Item('x')]) - >>> section['x'] + >>> section = Section("", items=[Item("x")]) + >>> section["x"] Item('x', '') - >>> section['y'] + >>> section["y"] Item('y', '') >>> section.items [Item('x', ''), Item('y', '')] @@ -344,96 +329,86 @@ def __getitem__(self, name: str) -> Item: self.items.append(item) return item - def __delitem__(self, name: str): + def __delitem__(self, name: str) -> None: """Delete an Item_ instance whose name is equal to `name`. Args: - ---- name: Item name. """ for k, item in enumerate(self.items): if item.name == name: del self.items[k] return - raise KeyError(f"name not found: {name}") + msg = f"name not found: {name}" + raise KeyError(msg) def __contains__(self, name: str) -> bool: - """Returns True if there is an [Item]() instance whose name is equal to `name`. + """Return True if there is an [Item]() instance whose name is equal to `name`. Args: - ---- name: Item name. """ - for item in self.items: - if item.name == name: - return True - return False + return any(item.name == name for item in self.items) - def set_item(self, item: Item, force: bool = False): - """Sets an [Item](). + def set_item(self, item: Item, *, force: bool = False) -> None: + """Set an [Item](). Args: - ---- item: Item instance. force: If True, overwrite self regardless of existing item. Examples: - -------- - >>> items = [Item('x', 'int'), Item('y', 'str', 'y')] - >>> section = Section('Parameters', items=items) - >>> section.set_item(Item('x', 'float', 'X')) - >>> section['x'].to_tuple() + >>> items = [Item("x", "int"), Item("y", "str", "y")] + >>> section = Section("Parameters", items=items) + >>> section.set_item(Item("x", "float", "X")) + >>> section["x"].to_tuple() ('x', 'int', 'X') - >>> section.set_item(Item('y', 'int', 'Y'), force=True) - >>> section['y'].to_tuple() + >>> section.set_item(Item("y", "int", "Y"), force=True) + >>> section["y"].to_tuple() ('y', 'int', 'Y') - >>> section.set_item(Item('z', 'float', 'Z')) + >>> section.set_item(Item("z", "float", "Z")) >>> [item.name for item in section.items] ['x', 'y', 'z'] See Also: - -------- * Section.update_ """ for k, x in enumerate(self.items): if x.name == item.name: - self.items[k].update(item, force) + self.items[k].update(item, force=force) return self.items.append(item.copy()) - def update(self, section: "Section", force: bool = False): - """Updates items. + def update(self, section: "Section", *, force: bool = False) -> None: + """Update items. Args: - ---- section: Section instance. force: If True, overwrite items of self regardless of existing value. Examples: - -------- - >>> s1 = Section('Parameters', items=[Item('a', 's'), Item('b', 'f')]) - >>> s2 = Section('Parameters', items=[Item('a', 'i', 'A'), Item('x', 'd')]) + >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) + >>> s2 = Section("Parameters", items=[Item("a", "i", "A"), Item("x", "d")]) >>> s1.update(s2) - >>> s1['a'].to_tuple() + >>> s1["a"].to_tuple() ('a', 's', 'A') - >>> s1['x'].to_tuple() + >>> s1["x"].to_tuple() ('x', 'd', '') >>> s1.update(s2, force=True) - >>> s1['a'].to_tuple() + >>> s1["a"].to_tuple() ('a', 'i', 'A') >>> s1.items [Item('a', 'i'), Item('b', 'f'), Item('x', 'd')] """ for item in section.items: - self.set_item(item, force) + self.set_item(item, force=force) - def merge(self, section: "Section", force: bool = False) -> "Section": - """Returns a merged Section + def merge(self, section: "Section", *, force: bool = False) -> "Section": + """Return a merged Section. - Examples - -------- - >>> s1 = Section('Parameters', items=[Item('a', 's'), Item('b', 'f')]) - >>> s2 = Section('Parameters', items=[Item('a', 'i'), Item('c', 'd')]) + Examples: + >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) + >>> s2 = Section("Parameters", items=[Item("a", "i"), Item("c", "d")]) >>> s3 = s1.merge(s2) >>> s3.items [Item('a', 's'), Item('b', 'f'), Item('c', 'd')] @@ -445,7 +420,8 @@ def merge(self, section: "Section", force: bool = False) -> "Section": [Item('a', 'i'), Item('c', 'd'), Item('b', 'f')] """ if section.name != self.name: - raise ValueError(f"Different name: {self.name} != {section.name}.") + msg = f"Different name: {self.name} != {section.name}." + raise ValueError(msg) merged = Section(self.name) for item in self.items: merged.set_item(item) @@ -453,12 +429,11 @@ def merge(self, section: "Section", force: bool = False) -> "Section": merged.set_item(item, force=force) return merged - def copy(self): - """Returns a copy of the {class} instace. + def copy(self): # noqa: ANN201 + """Return a copy of the {class} instace. - Examples - -------- - >>> s = Section('E', 'markdown', [Item('a', 's'), Item('b', 'i')]) + Examples: + >>> s = Section("E", "markdown", [Item("a", "s"), Item("b", "i")]) >>> s.copy() Section('E', num_items=2) """ @@ -471,15 +446,13 @@ def copy(self): @dataclass class Docstring: - """Docstring class represents a docstring of an object. + """Docstring of an object. Args: - ---- sections: List of Section instance. type: Type for Returns or Yields sections. Examples: - -------- Empty docstring: >>> docstring = Docstring() >>> assert not docstring @@ -502,38 +475,37 @@ class Docstring: Section ordering: >>> docstring = Docstring() - >>> _ = docstring[''] - >>> _ = docstring['Todo'] - >>> _ = docstring['Attributes'] - >>> _ = docstring['Parameters'] + >>> _ = docstring[""] + >>> _ = docstring["Todo"] + >>> _ = docstring["Attributes"] + >>> _ = docstring["Parameters"] >>> [section.name for section in docstring.sections] ['', 'Parameters', 'Attributes', 'Todo'] """ sections: list[Section] = field(default_factory=list) - type: Type = field(default_factory=Type) + type: Type = field(default_factory=Type) # noqa: A003 - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ num_sections = len(self.sections) return f"{class_name}(num_sections={num_sections})" - def __bool__(self): - """Returns True if the number of sections is larger than 0.""" + def __bool__(self) -> bool: + """Return True if the number of sections is larger than 0.""" return len(self.sections) > 0 def __iter__(self) -> Iterator[Base]: - """Yields [Base]() instance.""" + """Yield [Base]() instance.""" for section in self.sections: yield from section def __getitem__(self, name: str) -> Section: - """Returns a [Section]() instance whose name is equal to `name`. + """Return a [Section]() instance whose name is equal to `name`. If there is no Section instance, a Section instance is newly created. Args: - ---- name: Section name. """ for section in self.sections: @@ -543,50 +515,47 @@ def __getitem__(self, name: str) -> Section: self.set_section(section) return section - def __contains__(self, name) -> bool: - """Returns True if there is a [Section]() instance whose name is - equal to `name`. + def __contains__(self, name: str) -> bool: + """Return True if there is a [Section]() instance whose name is equal to `name`. Args: - ---- name: Section name. """ - for section in self.sections: - if section.name == name: - return True - return False + return any(section.name == name for section in self.sections) def set_section( self, section: Section, + *, force: bool = False, copy: bool = False, replace: bool = False, - ): - """Sets a [Section](). + ) -> None: + """Set a [Section](). Args: - ---- section: Section instance. force: If True, overwrite self regardless of existing seciton. + copy: If True, section is copied. + replace: If True,section is replaced. Examples: -------- - >>> items = [Item('x', 'int'), Item('y', 'str', 'y')] + >>> items = [Item("x", "int"), Item("y", "str", "y")] >>> s1 = Section('Attributes', items=items) - >>> items = [Item('x', 'str', 'X'), Item('z', 'str', 'z')] - >>> s2 = Section('Attributes', items=items) + >>> items = [Item("x", "str", "X"), Item("z", "str", "z")] + >>> s2 = Section("Attributes", items=items) >>> doc = Docstring([s1]) >>> doc.set_section(s2) - >>> doc['Attributes']['x'].to_tuple() + >>> doc["Attributes"]["x"].to_tuple() ('x', 'int', 'X') - >>> doc['Attributes']['z'].to_tuple() + >>> doc["Attributes"]["z"].to_tuple() ('z', 'str', 'z') >>> doc.set_section(s2, force=True) - >>> doc['Attributes']['x'].to_tuple() + >>> doc["Attributes"]["x"].to_tuple() ('x', 'str', 'X') - >>> items = [Item('x', 'X', 'str'), Item('z', 'z', 'str')] - >>> s3 = Section('Parameters', items=items) + >>> items = [Item("x", "X", "str"), Item("z", "z", "str")] + >>> s3 = Section("Parameters", items=items) >>> doc.set_section(s3) >>> doc.sections [Section('Parameters', num_items=2), Section('Attributes', num_items=3)] diff --git a/src/mkapi/examples/cls/member_order_sub.py b/src/mkapi/examples/cls/member_order_sub.py deleted file mode 100644 index dae57cb3..00000000 --- a/src/mkapi/examples/cls/member_order_sub.py +++ /dev/null @@ -1,11 +0,0 @@ -from mkapi.examples.cls.member_order_base import A - - -class B: - def b(self): - """mro index: 2, sourcefile index: 0, line number: 5.""" - - -class C(A, B): - def c(self): - """mro index: 0, sourcefile index: 0, line number: 10.""" diff --git a/tests/conftest.py b/tests/conftest.py index 67f634d2..b28d85b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -import mkapi.examples.styles.google as example +import examples.styles.google as example @pytest.fixture(scope="session") diff --git a/tests/core/test_core_attribute.py b/tests/core/test_core_attribute.py index 2bbaf935..7e097150 100644 --- a/tests/core/test_core_attribute.py +++ b/tests/core/test_core_attribute.py @@ -1,7 +1,7 @@ from dataclasses import dataclass +from examples.styles import google from mkapi.core.attribute import get_attributes, get_description -from mkapi.examples.styles import google from mkapi.inspect import signature diff --git a/tests/core/test_mro.py b/tests/core/test_mro.py index 49bf57fb..22e66a5a 100644 --- a/tests/core/test_mro.py +++ b/tests/core/test_mro.py @@ -1,9 +1,9 @@ +from examples import meta +from examples.meta import C, F from mkapi.core.base import Docstring from mkapi.core.docstring import parse_bases from mkapi.core.inherit import inherit from mkapi.core.node import get_node -from mkapi.examples import meta -from mkapi.examples.meta import C, F def test_mro_docstring(): From a9b950373155e70f6c989568e6a0e83a89813736 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 25 Dec 2023 22:08:30 +0900 Subject: [PATCH 006/148] preprocess.py --- src/mkapi/core/base.py | 8 +- src/mkapi/core/docstring.py | 15 ++- src/mkapi/core/filter.py | 40 +++++++ src/mkapi/core/{linker.py => link.py} | 0 src/mkapi/core/page.py | 8 +- src/mkapi/core/preprocess.py | 110 +++++++++++++----- src/mkapi/core/renderer.py | 41 ++++--- src/mkapi/core/structure.py | 6 +- src/mkapi/{core => inspect}/attribute.py | 86 ++++++++------ src/mkapi/inspect/signature.py | 4 +- src/mkapi/inspect/typing.py | 2 +- src/mkapi/plugins/api.py | 19 +-- src/mkapi/utils.py | 93 --------------- tests/core/test_core_linker.py | 22 ++-- tests/core/test_core_node.py | 2 +- tests/core/test_core_preprocess.py | 14 +-- .../test_inspect_attribute.py} | 2 +- 17 files changed, 248 insertions(+), 224 deletions(-) create mode 100644 src/mkapi/core/filter.py rename src/mkapi/core/{linker.py => link.py} (100%) rename src/mkapi/{core => inspect}/attribute.py (78%) delete mode 100644 src/mkapi/utils.py rename tests/{core/test_core_attribute.py => inspect/test_inspect_attribute.py} (98%) diff --git a/src/mkapi/core/base.py b/src/mkapi/core/base.py index 2e8b954a..1dcfe592 100644 --- a/src/mkapi/core/base.py +++ b/src/mkapi/core/base.py @@ -2,8 +2,8 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from mkapi.core import preprocess -from mkapi.core.linker import LINK_PATTERN +from mkapi.core.link import LINK_PATTERN +from mkapi.core.preprocess import add_fence, delete_ptags @dataclass @@ -90,7 +90,7 @@ def __post_init__(self) -> None: def set_html(self, html: str) -> None: """Set `html` attribute cleaning `p` tags.""" - html = preprocess.strip_ptags(html) + html = delete_ptags(html) super().set_html(html) def copy(self): # noqa: ANN201, D102 @@ -287,7 +287,7 @@ class Section(Base): def __post_init__(self) -> None: if self.markdown: - self.markdown = preprocess.convert(self.markdown) + self.markdown = add_fence(self.markdown) def __repr__(self) -> str: class_name = self.__class__.__name__ diff --git a/src/mkapi/core/docstring.py b/src/mkapi/core/docstring.py index 26de8267..f6800648 100644 --- a/src/mkapi/core/docstring.py +++ b/src/mkapi/core/docstring.py @@ -6,12 +6,11 @@ from dataclasses import is_dataclass from typing import Any -from mkapi.core import preprocess from mkapi.core.base import Docstring, Inline, Item, Section, Type -from mkapi.core.linker import get_link, replace_link +from mkapi.core.link import get_link, replace_link from mkapi.core.object import get_mro +from mkapi.core.preprocess import add_admonition, get_indent, join_without_indent from mkapi.inspect.signature import get_signature -from mkapi.utils import get_indent, join SECTIONS = [ "Args", @@ -94,14 +93,14 @@ def split_section(doc: str) -> Iterator[tuple[str, str, str]]: next_indent = get_indent(lines[stop]) if not line and next_indent < indent and name: if start < stop - 1: - yield name, join(lines[start : stop - 1]), style + yield name, join_without_indent(lines[start : stop - 1]), style start = stop name = "" else: section, style_ = section_heading(line) if section: if start < stop - 1: - yield name, join(lines[start : stop - 1]), style + yield name, join_without_indent(lines[start : stop - 1]), style style = style_ name = rename_section(section) start = stop @@ -109,7 +108,7 @@ def split_section(doc: str) -> Iterator[tuple[str, str, str]]: start += 1 indent = next_indent if start < len(lines): - yield name, join(lines[start:]), style + yield name, join_without_indent(lines[start:]), style def split_parameter(doc: str) -> Iterator[list[str]]: @@ -178,7 +177,7 @@ def parse_returns(doc: str, style: str) -> tuple[str, str]: else: type = lines[0].strip() lines = lines[1:] - return type, join(lines) + return type, join_without_indent(lines) def get_section(name: str, doc: str, style: str) -> Section: @@ -279,7 +278,7 @@ def postprocess(doc: Docstring, obj: Any): for base in section: base.markdown = replace_link(obj, base.markdown) if section.name in ["Note", "Notes", "Warning", "Warnings"]: - markdown = preprocess.admonition(section.name, section.markdown) + markdown = add_admonition(section.name, section.markdown) if sections and sections[-1].name == "": sections[-1].markdown += "\n\n" + markdown continue diff --git a/src/mkapi/core/filter.py b/src/mkapi/core/filter.py new file mode 100644 index 00000000..43ae5c33 --- /dev/null +++ b/src/mkapi/core/filter.py @@ -0,0 +1,40 @@ +"""Filter functions.""" + + +def split_filters(name): + """Examples: + >>> split_filters("a.b.c") + ('a.b.c', []) + >>> split_filters("a.b.c|upper|strict") + ('a.b.c', ['upper', 'strict']) + >>> split_filters("|upper|strict") + ('', ['upper', 'strict']) + >>> split_filters("") + ('', []) + """ + index = name.find("|") + if index == -1: + return name, [] + name, filters = name[:index], name[index + 1 :] + return name, filters.split("|") + + +def update_filters(org: list[str], update: list[str]) -> list[str]: + """Examples: + >>> update_filters(['upper'], ['lower']) + ['lower'] + >>> update_filters(['lower'], ['upper']) + ['upper'] + >>> update_filters(['long'], ['short']) + ['short'] + >>> update_filters(['short'], ['long']) + ['long'] + """ + filters = org + update + for x, y in [["lower", "upper"], ["long", "short"]]: + if x in org and y in update: + del filters[filters.index(x)] + if y in org and x in update: + del filters[filters.index(y)] + + return filters diff --git a/src/mkapi/core/linker.py b/src/mkapi/core/link.py similarity index 100% rename from src/mkapi/core/linker.py rename to src/mkapi/core/link.py diff --git a/src/mkapi/core/page.py b/src/mkapi/core/page.py index abd67657..3e1d5ee5 100644 --- a/src/mkapi/core/page.py +++ b/src/mkapi/core/page.py @@ -3,12 +3,12 @@ from collections.abc import Iterator from dataclasses import InitVar, dataclass, field -from mkapi import utils from mkapi.core import postprocess from mkapi.core.base import Base, Section from mkapi.core.code import Code, get_code +from mkapi.core.filter import split_filters, update_filters from mkapi.core.inherit import inherit -from mkapi.core.linker import resolve_link +from mkapi.core.link import resolve_link from mkapi.core.node import Node, get_node MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) @@ -70,11 +70,11 @@ def split(self, source: str) -> Iterator[str]: cursor = end heading, name = match.groups() level = len(heading) - name, filters = utils.split_filters(name) + name, filters = split_filters(name) if not name: self.filters = filters continue - filters = utils.update_filters(self.filters, filters) + filters = update_filters(self.filters, filters) if "code" in filters: code = get_code(name) self.nodes.append(code) diff --git a/src/mkapi/core/preprocess.py b/src/mkapi/core/preprocess.py index 8e5efd56..f307e690 100644 --- a/src/mkapi/core/preprocess.py +++ b/src/mkapi/core/preprocess.py @@ -1,41 +1,84 @@ -from typing import Iterator, Tuple +"""Preprocess functions.""" +from collections.abc import Iterator -def split_type(markdown: str) -> Tuple[str, str]: - line = markdown.split("\n")[0] - if ":" in line: - index = line.index(":") - type = line[:index].strip() +def split_type(markdown: str) -> tuple[str, str]: + """Return a tuple of (type, markdown) splitted by a colon. + + Examples: + >>> split_type("int : Integer value.") + ('int', 'Integer value.') + >>> split_type("abc") + ('', 'abc') + >>> split_type("") + ('', '') + """ + line = markdown.split("\n", maxsplit=1)[0] + if (index := line.find(":")) != -1: + type_ = line[:index].strip() markdown = markdown[index + 1 :].strip() - return type, markdown - else: - return "", markdown + return type_, markdown + return "", markdown -def strip_ptags(html: str) -> str: +def delete_ptags(html: str) -> str: + """Return HTML without

tag. + + Examples: + >>> delete_ptags("

para1

para2

") + 'para1
para2' + """ html = html.replace("

", "").replace("

", "
") if html.endswith("
"): html = html[:-4] return html -def convert(text: str) -> str: - blocks = [] - for block in split(text): - if block.startswith(">>>"): - block = f"~~~python\n{block}\n~~~\n" - blocks.append(block) - return "\n".join(blocks).strip() +def get_indent(line: str) -> int: + """Return the number of indent of a line. + Examples: + >>> get_indent("abc") + 0 + >>> get_indent(" abc") + 2 + >>> get_indent("") + -1 + """ + for k, x in enumerate(line): + if x != " ": + return k + return -1 -def delete_indent(lines, start, stop): - from mkapi.core.docstring import get_indent +def join_without_indent( + lines: list[str], + start: int = 0, + stop: int | None = None, +) -> str: + r"""Return a joint string without indent. + + Examples: + >>> join_without_indent(["abc", "def"]) + 'abc\ndef' + >>> join_without_indent([" abc", " def"]) + 'abc\ndef' + >>> join_without_indent([" abc", " def ", ""]) + 'abc\n def' + >>> join_without_indent([" abc", " def", " ghi"]) + 'abc\n def\n ghi' + >>> join_without_indent([" abc", " def", " ghi"], stop=2) + 'abc\n def' + >>> join_without_indent([]) + '' + """ + if not lines: + return "" indent = get_indent(lines[start]) - return "\n".join(x[indent:] for x in lines[start:stop]).strip() + return "\n".join(line[indent:] for line in lines[start:stop]).strip() -def split(text: str) -> Iterator[str]: +def _splitter(text: str) -> Iterator[str]: start = 0 in_code = False lines = text.split("\n") @@ -46,20 +89,31 @@ def split(text: str) -> Iterator[str]: start = stop - 1 in_code = True elif not line.strip() and in_code: - yield delete_indent(lines, start, stop) + yield join_without_indent(lines, start, stop) start = stop in_code = False if start < len(lines): - yield delete_indent(lines, start, len(lines)) + yield join_without_indent(lines, start, len(lines)) + + +def add_fence(text: str) -> str: + """Add fence in `>>>` statements.""" + blocks = [] + for block in _splitter(text): + if block.startswith(">>>"): + block = f"~~~python\n{block}\n~~~\n" # noqa: PLW2901 + blocks.append(block) + return "\n".join(blocks).strip() -def admonition(name: str, markdown: str) -> str: +def add_admonition(name: str, markdown: str) -> str: + """Add admonition in note and/or warning sections.""" if name.startswith("Note"): - type = "note" + kind = "note" elif name.startswith("Warning"): - type = "warning" + kind = "warning" else: - type = name.lower() + kind = name.lower() lines = [" " + line if line else "" for line in markdown.split("\n")] - lines.insert(0, f'!!! {type} "{name}"') + lines.insert(0, f'!!! {kind} "{name}"') return "\n".join(lines) diff --git a/src/mkapi/core/renderer.py b/src/mkapi/core/renderer.py index e7dde340..318a0045 100644 --- a/src/mkapi/core/renderer.py +++ b/src/mkapi/core/renderer.py @@ -1,5 +1,4 @@ -""" -This module provides Renderer class that renders Node instance +"""This module provides Renderer class that renders Node instance to create API documentation. """ import os @@ -9,7 +8,7 @@ from jinja2 import Environment, FileSystemLoader, Template, select_autoescape import mkapi -from mkapi.core import linker +from mkapi.core import link from mkapi.core.base import Docstring, Section from mkapi.core.code import Code from mkapi.core.module import Module @@ -26,7 +25,7 @@ class Renderer: templates: Jinja template dictionary. """ - templates: Dict[str, Template] = field(default_factory=dict, init=False) + templates: dict[str, Template] = field(default_factory=dict, init=False) def __post_init__(self): path = os.path.join(os.path.dirname(mkapi.__file__), "templates") @@ -37,7 +36,7 @@ def __post_init__(self): name = os.path.splitext(name)[0] self.templates[name] = template - def render(self, node: Node, filters: List[str] = None) -> str: + def render(self, node: Node, filters: list[str] = None) -> str: """Returns a rendered HTML for Node. Args: @@ -49,7 +48,11 @@ def render(self, node: Node, filters: List[str] = None) -> str: return self.render_node(node, object, docstring, members) def render_node( - self, node: Node, object: str, docstring: str, members: List[str] + self, + node: Node, + object: str, + docstring: str, + members: list[str], ) -> str: """Returns a rendered HTML for Node using prerendered components. @@ -61,10 +64,13 @@ def render_node( """ template = self.templates["node"] return template.render( - node=node, object=object, docstring=docstring, members=members + node=node, + object=object, + docstring=docstring, + members=members, ) - def render_object(self, object: Object, filters: List[str] = None) -> str: + def render_object(self, object: Object, filters: list[str] = None) -> str: """Returns a rendered HTML for Object. Args: @@ -73,7 +79,7 @@ def render_object(self, object: Object, filters: List[str] = None) -> str: """ if filters is None: filters = [] - context = linker.resolve_object(object.html) + context = link.resolve_object(object.html) level = context.get("level") if level: if object.kind in ["module", "package"]: @@ -87,7 +93,10 @@ def render_object(self, object: Object, filters: List[str] = None) -> str: return template.render(context, object=object, tag=tag, filters=filters) def render_object_member( - self, name: str, url: str, signature: Dict[str, Any] + self, + name: str, + url: str, + signature: dict[str, Any], ) -> str: """Returns a rendered HTML for Object in toc. @@ -99,7 +108,7 @@ def render_object_member( template = self.templates["member"] return template.render(name=name, url=url, signature=signature) - def render_docstring(self, docstring: Docstring, filters: List[str] = None) -> str: + def render_docstring(self, docstring: Docstring, filters: list[str] = None) -> str: """Returns a rendered HTML for Docstring. Args: @@ -115,7 +124,7 @@ def render_docstring(self, docstring: Docstring, filters: List[str] = None) -> s section.html = self.render_section(section, filters) return template.render(docstring=docstring) - def render_section(self, section: Section, filters: List[str] = None) -> str: + def render_section(self, section: Section, filters: list[str] = None) -> str: """Returns a rendered HTML for Section. Args: @@ -128,7 +137,7 @@ def render_section(self, section: Section, filters: List[str] = None) -> str: else: return self.templates["items"].render(section=section, filters=filters) - def render_module(self, module: Module, filters: List[str] = None) -> str: + def render_module(self, module: Module, filters: list[str] = None) -> str: """Returns a rendered Markdown for Module. Args: @@ -151,10 +160,12 @@ def render_module(self, module: Module, filters: List[str] = None) -> str: object_filter = "|" + "|".join(filters) template = self.templates["module"] return template.render( - module=module, module_filter=module_filter, object_filter=object_filter + module=module, + module_filter=module_filter, + object_filter=object_filter, ) - def render_code(self, code: Code, filters: List[str] = None) -> str: + def render_code(self, code: Code, filters: list[str] = None) -> str: if filters is None: filters = [] template = self.templates["code"] diff --git a/src/mkapi/core/structure.py b/src/mkapi/core/structure.py index bcc7d908..cdccceab 100644 --- a/src/mkapi/core/structure.py +++ b/src/mkapi/core/structure.py @@ -46,7 +46,7 @@ class Object(Base): type: Type = field(default_factory=Type, init=False) def __post_init__(self): - from mkapi.core import linker + from mkapi.core import link self.id = self.name if self.prefix: @@ -56,9 +56,9 @@ def __post_init__(self): else: self.module = self.id[: -len(self.qualname) - 1] if not self.markdown: - name = linker.link(self.name, self.id) + name = link.link(self.name, self.id) if self.prefix: - prefix = linker.link(self.prefix, self.prefix) + prefix = link.link(self.prefix, self.prefix) self.markdown = ".".join([prefix, name]) else: self.markdown = name diff --git a/src/mkapi/core/attribute.py b/src/mkapi/inspect/attribute.py similarity index 78% rename from src/mkapi/core/attribute.py rename to src/mkapi/inspect/attribute.py index b8137cfb..8991630b 100644 --- a/src/mkapi/core/attribute.py +++ b/src/mkapi/inspect/attribute.py @@ -1,20 +1,29 @@ -"""This module provides functions that inspect attributes from source code.""" +"""Functions that inspect attributes from source code.""" +from __future__ import annotations + +import _ast import ast +import dataclasses import importlib import inspect -from dataclasses import InitVar, is_dataclass +from ast import AST from functools import lru_cache -from typing import Any, Dict, Iterable, List, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Tuple -import _ast -from mkapi import utils +from mkapi.core import preprocess + +if TYPE_CHECKING: + from collections.abc import Iterable + from types import ModuleType + + from _typeshed import DataclassInstance def parse_attribute(x) -> str: return ".".join([parse_node(x.value), x.attr]) -def parse_attribute_with_lineno(x) -> Tuple[str, int]: +def parse_attribute_with_lineno(x) -> tuple[str, int]: return parse_node(x), x.lineno @@ -61,13 +70,13 @@ def parse_node(x): raise NotImplementedError -def parse_annotation_assign(assign) -> Tuple[str, int, str]: +def parse_annotation_assign(assign) -> tuple[str, int, str]: type = parse_node(assign.annotation) attr, lineno = parse_attribute_with_lineno(assign.target) return attr, lineno, type -def get_description(lines: List[str], lineno: int) -> str: +def get_description(lines: list[str], lineno: int) -> str: index = lineno - 1 line = lines[index] if " #: " in line: @@ -109,10 +118,12 @@ def get_source(obj) -> str: def get_attributes_with_lineno( - nodes: Iterable[ast.AST], module, is_module: bool = False -) -> List[Tuple[str, int, Any]]: - attr_dict: Dict[Tuple[str, int], Any] = {} - linenos: Dict[int, int] = {} + nodes: Iterable[ast.AST], + module, + is_module: bool = False, +) -> list[tuple[str, int, Any]]: + attr_dict: dict[tuple[str, int], Any] = {} + linenos: dict[int, int] = {} def update(attr, lineno, type): if type or (attr, lineno) not in attr_dict: @@ -141,10 +152,11 @@ def update(attr, lineno, type): def get_attributes_dict( - attr_list: List[Tuple[str, int, Any]], source: str, prefix: str = "" -) -> Dict[str, Tuple[Any, str]]: - - attrs: Dict[str, Tuple[Any, str]] = {} + attr_list: list[tuple[str, int, Any]], + source: str, + prefix: str = "", +) -> dict[str, tuple[Any, str]]: + attrs: dict[str, tuple[Any, str]] = {} lines = source.split("\n") for k, (name, lineno, type) in enumerate(attr_list): if not prefix or name.startswith(prefix): @@ -160,7 +172,7 @@ def get_attributes_dict( return attrs -def get_class_attributes(cls) -> Dict[str, Tuple[Any, str]]: +def get_class_attributes(cls) -> dict[str, tuple[Any, str]]: """Returns a dictionary that maps attribute name to a tuple of (type, description). @@ -182,7 +194,7 @@ def get_class_attributes(cls) -> Dict[str, Tuple[Any, str]]: source = get_source(cls) if not source: return {} - source = utils.join(source.split("\n")) + source = preprocess.join(source.split("\n")) node = ast.parse(source) nodes = ast.walk(node) module = importlib.import_module(cls.__module__) @@ -190,9 +202,10 @@ def get_class_attributes(cls) -> Dict[str, Tuple[Any, str]]: return get_attributes_dict(attr_lineno, source, prefix="self.") -def get_dataclass_attributes(cls) -> Dict[str, Tuple[Any, str]]: - """Returns a dictionary that maps attribute name to a tuple of - (type, description). +def get_dataclass_attributes( + cls: type[DataclassInstance], +) -> dict[str, tuple[Any, str]]: + """Return a dictionary that maps attribute name to a tuple of (type, description). Args: cls: Dataclass object. @@ -200,19 +213,18 @@ def get_dataclass_attributes(cls) -> Dict[str, Tuple[Any, str]]: Examples: >>> from mkapi.core.base import Item, Type, Inline >>> attrs = get_dataclass_attributes(Item) - >>> attrs['type'][0] is Type + >>> attrs["type"][0] is Type True - >>> attrs['description'][0] is Inline + >>> attrs["description"][0] is Inline True """ - fields = cls.__dataclass_fields__.values() attrs = {} - for field in fields: - if field.type != InitVar: + for field in dataclasses.fields(cls): + if field.type != dataclasses.InitVar: attrs[field.name] = field.type, "" source = get_source(cls) - source = utils.join(source.split("\n")) + source = preprocess.join(source.split("\n")) if not source: return {} node = ast.parse(source).body[0] @@ -234,9 +246,8 @@ def nodes(): return attrs -def get_module_attributes(module) -> Dict[str, Tuple[Any, str]]: - """Returns a dictionary that maps attribute name to a tuple of - (type, description). +def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: + """Return a dictionary that maps attribute name to a tuple of (type, description). Args: module: Module object. @@ -244,7 +255,7 @@ def get_module_attributes(module) -> Dict[str, Tuple[Any, str]]: Examples: >>> from mkapi.core import renderer >>> attrs = get_module_attributes(renderer) - >>> attrs['renderer'][0] is renderer.Renderer + >>> attrs["renderer"][0] is renderer.Renderer True """ source = get_source(module) @@ -257,20 +268,19 @@ def get_module_attributes(module) -> Dict[str, Tuple[Any, str]]: @lru_cache(maxsize=1000) -def get_attributes(obj) -> Dict[str, Tuple[Any, str]]: - """Returns a dictionary that maps attribute name to - a tuple of (type, description). +def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: + """Return a dictionary that maps attribute name to a tuple of (type, description). Args: obj: Object. - See Alse: + See Also: get_class_attributes_, get_dataclass_attributes_, get_module_attributes_. """ - if is_dataclass(obj): + if dataclasses.is_dataclass(obj) and isinstance(obj, type): return get_dataclass_attributes(obj) - elif inspect.isclass(obj): + if inspect.isclass(obj): return get_class_attributes(obj) - elif inspect.ismodule(obj): + if inspect.ismodule(obj): return get_module_attributes(obj) return {} diff --git a/src/mkapi/inspect/signature.py b/src/mkapi/inspect/signature.py index 41cebf9e..ad7424e2 100644 --- a/src/mkapi/inspect/signature.py +++ b/src/mkapi/inspect/signature.py @@ -5,8 +5,8 @@ from typing import Any from mkapi.core import preprocess -from mkapi.core.attribute import get_attributes from mkapi.core.base import Inline, Item, Section, Type +from mkapi.inspect.attribute import get_attributes from mkapi.inspect.typing import to_string @@ -63,7 +63,7 @@ def __str__(self) -> str: @property def arguments(self) -> list[str] | None: - """Returns arguments list.""" + """Return argument list.""" if self.obj is None or not callable(self.obj): return None diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index 960e0152..a45bf8bc 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -4,7 +4,7 @@ from types import UnionType from typing import ForwardRef, Union, get_args, get_origin -from mkapi.core.linker import get_link +from mkapi.core.link import get_link def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001, PLR0911 diff --git a/src/mkapi/plugins/api.py b/src/mkapi/plugins/api.py index d712a2e7..6abe1a66 100644 --- a/src/mkapi/plugins/api.py +++ b/src/mkapi/plugins/api.py @@ -5,7 +5,7 @@ import sys from typing import Dict, List, Optional, Tuple -from mkapi import utils +from mkapi.core import preprocess from mkapi.core.module import Module, get_module logger = logging.getLogger("mkdocs") @@ -21,13 +21,16 @@ def create_nav(config, global_filters): for key, value in page.items(): if isinstance(value, str) and value.startswith("mkapi/"): page[key], abs_api_paths_ = collect( - value, docs_dir, config_dir, global_filters + value, + docs_dir, + config_dir, + global_filters, ) abs_api_paths.extend(abs_api_paths_) return config, abs_api_paths -def collect(path: str, docs_dir: str, config_dir, global_filters) -> Tuple[list, list]: +def collect(path: str, docs_dir: str, config_dir, global_filters) -> tuple[list, list]: _, api_path, *paths, package_path = path.split("/") abs_api_path = os.path.join(docs_dir, api_path) if os.path.exists(abs_api_path): @@ -41,12 +44,12 @@ def collect(path: str, docs_dir: str, config_dir, global_filters) -> Tuple[list, if root not in sys.path: sys.path.insert(0, root) - package_path, filters = utils.split_filters(package_path) - filters = utils.update_filters(global_filters, filters) + package_path, filters = preprocess.split_filters(package_path) + filters = preprocess.update_filters(global_filters, filters) nav = [] abs_api_paths = [] - modules: Dict[str, str] = {} + modules: dict[str, str] = {} package = None def add_page(module: Module, package: Optional[str]): @@ -80,12 +83,12 @@ def add_page(module: Module, package: Optional[str]): return nav, abs_api_paths -def create_page(path: str, module: Module, filters: List[str]): +def create_page(path: str, module: Module, filters: list[str]): with open(path, "w") as f: f.write(module.get_markdown(filters)) -def create_source_page(path: str, module: Module, filters: List[str]): +def create_source_page(path: str, module: Module, filters: list[str]): filters_str = "|".join(filters) with open(path, "w") as f: f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py deleted file mode 100644 index c7338b57..00000000 --- a/src/mkapi/utils.py +++ /dev/null @@ -1,93 +0,0 @@ -import importlib -from typing import Any - - -def get_indent(line: str) -> int: - indent = 0 - for x in line: - if x != " ": - return indent - indent += 1 - return -1 - - -def join(lines): - if not len(lines): - return "" - indent = get_indent(lines[0]) - return "\n".join(line[indent:] for line in lines).strip() - - -def get_object(name: str) -> Any: - """Reutrns an object specified by `name`. - - Args: - name: Object name. - - Examples: - >>> import inspect - >>> obj = get_object('mkapi.core') - >>> inspect.ismodule(obj) - True - >>> obj = get_object('mkapi.core.base') - >>> inspect.ismodule(obj) - True - >>> obj = get_object('mkapi.core.node.Node') - >>> inspect.isclass(obj) - True - >>> obj = get_object('mkapi.core.node.Node.get_markdown') - >>> inspect.isfunction(obj) - True - """ - names = name.split(".") - for k in range(len(names), 0, -1): - module_name = ".".join(names[:k]) - try: - obj = importlib.import_module(module_name) - except ModuleNotFoundError: - continue - for attr in names[k:]: - obj = getattr(obj, attr) - return obj - raise ValueError(f"Could not find object: {name}") - - -def split_filters(name): - """ - Examples: - >>> split_filters("a.b.c") - ('a.b.c', []) - >>> split_filters("a.b.c|upper|strict") - ('a.b.c', ['upper', 'strict']) - >>> split_filters("|upper|strict") - ('', ['upper', 'strict']) - >>> split_filters("") - ('', []) - """ - index = name.find("|") - if index == -1: - return name, [] - name, filters = name[:index], name[index + 1 :] - return name, filters.split("|") - - -def update_filters(org: list[str], update: list[str]) -> list[str]: - """ - Examples: - >>> update_filters(['upper'], ['lower']) - ['lower'] - >>> update_filters(['lower'], ['upper']) - ['upper'] - >>> update_filters(['long'], ['short']) - ['short'] - >>> update_filters(['short'], ['long']) - ['long'] - """ - filters = org + update - for x, y in [["lower", "upper"], ["long", "short"]]: - if x in org and y in update: - del filters[filters.index(x)] - if y in org and x in update: - del filters[filters.index(y)] - - return filters diff --git a/tests/core/test_core_linker.py b/tests/core/test_core_linker.py index ff74d8a2..d1e1afe0 100644 --- a/tests/core/test_core_linker.py +++ b/tests/core/test_core_linker.py @@ -1,4 +1,4 @@ -from mkapi.core import linker +from mkapi.core import link def test_get_link_private(): @@ -11,26 +11,26 @@ def _private(): q = "test_get_link_private..A" m = "test_core_linker" - assert linker.get_link(A) == f"[{q}](!{m}.{q})" - assert linker.get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" - assert linker.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" - assert linker.get_link(A._private) == f"{q}._private" + assert link.get_link(A) == f"[{q}](!{m}.{q})" + assert link.get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" + assert link.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" + assert link.get_link(A._private) == f"{q}._private" def test_resolve_link(): - assert linker.resolve_link("[A](!!a)", "", []) == "[A](a)" - assert linker.resolve_link("[A](!a)", "", []) == "A" - assert linker.resolve_link("[A](a)", "", []) == "[A](a)" + assert link.resolve_link("[A](!!a)", "", []) == "[A](a)" + assert link.resolve_link("[A](!a)", "", []) == "A" + assert link.resolve_link("[A](a)", "", []) == "[A](a)" def test_resolve_href(): - assert linker.resolve_href("", "", []) == "" + assert link.resolve_href("", "", []) == "" def test_resolve_object(): html = "

p

" - context = linker.resolve_object(html) + context = link.resolve_object(html) assert context == {"heading_id": "", "level": 0, "prefix_url": "", "name_url": "a"} html = "

pn

" - context = linker.resolve_object(html) + context = link.resolve_object(html) assert context == {"heading_id": "", "level": 0, "prefix_url": "a", "name_url": "b"} diff --git a/tests/core/test_core_node.py b/tests/core/test_core_node.py index ed7ac75c..29ab41a1 100644 --- a/tests/core/test_core_node.py +++ b/tests/core/test_core_node.py @@ -143,7 +143,7 @@ def test_set_html_and_render_bases(): def test_decorated_member(): - from mkapi.core import attribute + from mkapi.inspect import attribute from mkapi.inspect.signature import Signature node = get_node(attribute) diff --git a/tests/core/test_core_preprocess.py b/tests/core/test_core_preprocess.py index 338445ef..df251fa1 100644 --- a/tests/core/test_core_preprocess.py +++ b/tests/core/test_core_preprocess.py @@ -1,4 +1,4 @@ -from mkapi.core.preprocess import admonition, convert +from mkapi.core.preprocess import add_admonition, add_fence source = """ABC @@ -30,14 +30,14 @@ GHI""" -def test_convert(): - assert convert(source) == output +def test_add_fence(): + assert add_fence(source) == output -def test_admonition(): - markdown = admonition("Warnings", "abc\n\ndef") +def test_add_admonition(): + markdown = add_admonition("Warnings", "abc\n\ndef") assert markdown == '!!! warning "Warnings"\n abc\n\n def' - markdown = admonition("Note", "abc\n\ndef") + markdown = add_admonition("Note", "abc\n\ndef") assert markdown == '!!! note "Note"\n abc\n\n def' - markdown = admonition("Tips", "abc\n\ndef") + markdown = add_admonition("Tips", "abc\n\ndef") assert markdown == '!!! tips "Tips"\n abc\n\n def' diff --git a/tests/core/test_core_attribute.py b/tests/inspect/test_inspect_attribute.py similarity index 98% rename from tests/core/test_core_attribute.py rename to tests/inspect/test_inspect_attribute.py index 7e097150..70b4fb36 100644 --- a/tests/core/test_core_attribute.py +++ b/tests/inspect/test_inspect_attribute.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from examples.styles import google -from mkapi.core.attribute import get_attributes, get_description from mkapi.inspect import signature +from mkapi.inspect.attribute import get_attributes, get_description class A: From 1b5f86f9fd111d382645ff461b76fb88b86da0b8 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 25 Dec 2023 22:15:31 +0900 Subject: [PATCH 007/148] link.py --- src/mkapi/core/base.py | 3 --- src/mkapi/core/filter.py | 42 ++++++++++++++++++++++------------------ src/mkapi/core/link.py | 10 ---------- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/mkapi/core/base.py b/src/mkapi/core/base.py index 1dcfe592..7daa33d9 100644 --- a/src/mkapi/core/base.py +++ b/src/mkapi/core/base.py @@ -130,14 +130,12 @@ class Item(Type): """Item in Parameters, Attributes, and Raises sections, *etc.*. Args: - ---- type: Type of self. description: Description of self. kind: Kind of self, for example `readonly property`. This value is rendered as a class attribute in HTML. Examples: - -------- >>> item = Item('[x](x)', Type('int'), Inline('A parameter.')) >>> item Item('[x](x)', 'int') @@ -540,7 +538,6 @@ def set_section( replace: If True,section is replaced. Examples: - -------- >>> items = [Item("x", "int"), Item("y", "str", "y")] >>> s1 = Section('Attributes', items=items) >>> items = [Item("x", "str", "X"), Item("z", "str", "z")] diff --git a/src/mkapi/core/filter.py b/src/mkapi/core/filter.py index 43ae5c33..db115974 100644 --- a/src/mkapi/core/filter.py +++ b/src/mkapi/core/filter.py @@ -1,16 +1,18 @@ """Filter functions.""" -def split_filters(name): - """Examples: - >>> split_filters("a.b.c") - ('a.b.c', []) - >>> split_filters("a.b.c|upper|strict") - ('a.b.c', ['upper', 'strict']) - >>> split_filters("|upper|strict") - ('', ['upper', 'strict']) - >>> split_filters("") - ('', []) +def split_filters(name: str) -> tuple[str, list[str]]: + """Split filters written after `|`s. + + Examples: + >>> split_filters("a.b.c") + ('a.b.c', []) + >>> split_filters("a.b.c|upper|strict") + ('a.b.c', ['upper', 'strict']) + >>> split_filters("|upper|strict") + ('', ['upper', 'strict']) + >>> split_filters("") + ('', []) """ index = name.find("|") if index == -1: @@ -20,15 +22,17 @@ def split_filters(name): def update_filters(org: list[str], update: list[str]) -> list[str]: - """Examples: - >>> update_filters(['upper'], ['lower']) - ['lower'] - >>> update_filters(['lower'], ['upper']) - ['upper'] - >>> update_filters(['long'], ['short']) - ['short'] - >>> update_filters(['short'], ['long']) - ['long'] + """Update filters. + + Examples: + >>> update_filters(['upper'], ['lower']) + ['lower'] + >>> update_filters(['lower'], ['upper']) + ['upper'] + >>> update_filters(['long'], ['short']) + ['short'] + >>> update_filters(['short'], ['long']) + ['long'] """ filters = org + update for x, y in [["lower", "upper"], ["long", "short"]]: diff --git a/src/mkapi/core/link.py b/src/mkapi/core/link.py index bd5631a1..df4aa0a7 100644 --- a/src/mkapi/core/link.py +++ b/src/mkapi/core/link.py @@ -15,12 +15,10 @@ def link(name: str, href: str) -> str: """Return Markdown link with a mark that indicates this link was created by MkAPI. Args: - ---- name: Link name. href: Reference. Examples: - -------- >>> link('abc', 'xyz') '[abc](!xyz)' """ @@ -31,12 +29,10 @@ def get_link(obj: type, *, include_module: bool = False) -> str: """Return Markdown link for object, if possible. Args: - ---- obj: Object include_module: If True, link text includes module path. Examples: - -------- >>> get_link(int) 'int' >>> get_link(get_fullname) @@ -64,13 +60,11 @@ def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> """Reutrn resolved link. Args: - ---- markdown: Markdown source. abs_src_path: Absolute source path of Markdown. abs_api_paths: List of API paths. Examples: - -------- >>> abs_src_path = '/src/examples/example.md' >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] >>> resolve_link('[abc](!b.c.d)', abs_src_path, abs_api_paths) @@ -159,11 +153,9 @@ def resolve_object(html: str) -> dict[str, Any]: """Reutrn an object context dictionary. Args: - ---- html: HTML source. Examples: - -------- >>> resolve_object("

pn

") {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} >>> resolve_object("

pn

") @@ -177,12 +169,10 @@ def replace_link(obj: object, markdown: str) -> str: """Return a replaced link with object's full name. Args: - ---- obj: Object that has a module. markdown: Markdown Examples: - -------- >>> from mkapi.core.object import get_object >>> obj = get_object('mkapi.core.structure.Object') >>> replace_link(obj, '[Signature]()') From 3432de4632f987fb06cd6938c1db7c1e75dd2771 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 25 Dec 2023 22:17:00 +0900 Subject: [PATCH 008/148] typing.py --- src/mkapi/inspect/typing.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index a45bf8bc..e8cc26a0 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -83,12 +83,10 @@ def resolve_forward_ref(name: str, obj: object) -> str: """Return a resolved name for `str` or `typing.ForwardRef`. Args: - ---- name: Forward reference name. obj: Object Examples: - -------- >>> from mkapi.core.base import Docstring >>> resolve_forward_ref('Docstring', Docstring) '[Docstring](!mkapi.core.base.Docstring)' @@ -111,12 +109,10 @@ def resolve_orign_args(tp, obj: object = None) -> str: # noqa: ANN001 """Return string expression for X[Y, Z, ...]. Args: - ---- tp: type obj: Object Examples: - -------- >>> resolve_orign_args(list[str]) 'list[str]' >>> from typing import List, Tuple @@ -160,11 +156,9 @@ def _to_string_for_yields(tp, obj: object) -> str: # noqa: ANN001 # """Return "A of B" style string. # Args: -# ---- # annotation: Annotation # Examples: -# -------- # >>> from typing import List, Iterable, Iterator # >>> a_of_b(List[str]) # 'list of str' @@ -191,11 +185,9 @@ def _to_string_for_yields(tp, obj: object) -> str: # noqa: ANN001 # """Return a string for union annotation. # Args: -# ---- # annotation: Annotation # Examples: -# -------- # >>> from typing import List, Optional, Tuple, Union # >>> union(Optional[List[str]]) # 'list of str, optional' @@ -228,11 +220,9 @@ def _to_string_for_yields(tp, obj: object) -> str: # noqa: ANN001 # """Return a string for callable and generator annotation. # Args: -# ---- # annotation: Annotation # Examples: -# -------- # >>> from typing import Callable, List, Tuple, Any # >>> from typing import Generator, AsyncGenerator # >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) From 08104644b110ec8412efccdbc51e35f9aa6622c0 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Tue, 26 Dec 2023 23:20:11 +0900 Subject: [PATCH 009/148] type_string --- pyproject.toml | 2 +- src/mkapi/inspect/attribute.py | 72 +++--- src/mkapi/inspect/signature.py | 20 +- src/mkapi/inspect/typing.py | 308 ++++++++---------------- tests/inspect/test_inspect_signature.py | 14 +- tests/inspect/test_inspect_typing.py | 12 + 6 files changed, 163 insertions(+), 265 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8114417..f52b396f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] omit = ["src/mkapi/__about__.py"] [tool.coverage.report] -exclude_lines = ["no cov"] +exclude_lines = ["no cov", "raise NotImplementedError"] [tool.hatch.envs.docs] dependencies = ["mkdocs", "mkdocs-material"] diff --git a/src/mkapi/inspect/attribute.py b/src/mkapi/inspect/attribute.py index 8991630b..ced1b9cf 100644 --- a/src/mkapi/inspect/attribute.py +++ b/src/mkapi/inspect/attribute.py @@ -8,12 +8,12 @@ import inspect from ast import AST from functools import lru_cache -from typing import TYPE_CHECKING, Any, Dict, List, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, TypeGuard -from mkapi.core import preprocess +from mkapi.core.preprocess import join_without_indent if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Iterator from types import ModuleType from _typeshed import DataclassInstance @@ -106,20 +106,18 @@ def get_description(lines: list[str], lineno: int) -> str: return "" -def get_source(obj) -> str: +def get_source(obj) -> str: # noqa: ANN001 + """Return the text of the source code for an object without exception.""" try: - source = inspect.getsource(obj) or "" - if not source: - return "" - except (OSError, TypeError): + return inspect.getsource(obj) + except OSError: return "" - else: - return source def get_attributes_with_lineno( - nodes: Iterable[ast.AST], - module, + nodes: Iterable[AST], + module: ModuleType, + *, is_module: bool = False, ) -> list[tuple[str, int, Any]]: attr_dict: dict[tuple[str, int], Any] = {} @@ -172,29 +170,26 @@ def get_attributes_dict( return attrs -def get_class_attributes(cls) -> dict[str, tuple[Any, str]]: - """Returns a dictionary that maps attribute name to a tuple of - (type, description). +def get_class_attributes(cls: type[Any]) -> dict[str, tuple[Any, str]]: + """Return a dictionary that maps attribute name to a tuple of (type, description). Args: cls: Class object. Examples: - >>> from examples.google_style import ExampleClass - >>> attrs = get_class_attributes(ExampleClass) - >>> attrs['a'][0] is str + >>> from mkapi.core.base import Base + >>> attrs = get_class_attributes(Base) + >>> attrs["name"][0] is str True - >>> attrs['a'][1] - 'The first attribute. Comment *inline* with attribute.' - >>> attrs['b'][0] == Dict[str, int] - True - >>> attrs['c'][0] is None + >>> attrs["name"][1] + 'Name of self.' + >>> attrs["callback"][0] True """ source = get_source(cls) if not source: return {} - source = preprocess.join(source.split("\n")) + source = join_without_indent(source.split("\n")) node = ast.parse(source) nodes = ast.walk(node) module = importlib.import_module(cls.__module__) @@ -224,12 +219,12 @@ def get_dataclass_attributes( attrs[field.name] = field.type, "" source = get_source(cls) - source = preprocess.join(source.split("\n")) + source = join_without_indent(source.split("\n")) if not source: return {} node = ast.parse(source).body[0] - def nodes(): + def nodes() -> Iterator[AST]: for x in ast.iter_child_nodes(node): if isinstance(x, _ast.FunctionDef): break @@ -237,11 +232,11 @@ def nodes(): module = importlib.import_module(cls.__module__) attr_lineno = get_attributes_with_lineno(nodes(), module) - for name, (type, description) in get_attributes_dict(attr_lineno, source).items(): + for name, (type_, description) in get_attributes_dict(attr_lineno, source).items(): if name in attrs: attrs[name] = attrs[name][0], description else: - attrs[name] = type, description + attrs[name] = type_, description return attrs @@ -267,6 +262,18 @@ def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: return get_attributes_dict(attr_lineno, source) +def isdataclass(obj: object) -> TypeGuard[type[DataclassInstance]]: + """Return True if obj is a dataclass.""" + return dataclasses.is_dataclass(obj) and isinstance(obj, type) + + +ATTRIBUTES_FUNCTIONS = [ + (isdataclass, get_dataclass_attributes), + (inspect.isclass, get_class_attributes), + (inspect.ismodule, get_module_attributes), +] + + @lru_cache(maxsize=1000) def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: """Return a dictionary that maps attribute name to a tuple of (type, description). @@ -277,10 +284,7 @@ def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: See Also: get_class_attributes_, get_dataclass_attributes_, get_module_attributes_. """ - if dataclasses.is_dataclass(obj) and isinstance(obj, type): - return get_dataclass_attributes(obj) - if inspect.isclass(obj): - return get_class_attributes(obj) - if inspect.ismodule(obj): - return get_module_attributes(obj) + for is_, get in ATTRIBUTES_FUNCTIONS: + if is_(obj): + return get(obj) return {} diff --git a/src/mkapi/inspect/signature.py b/src/mkapi/inspect/signature.py index ad7424e2..c81b5f35 100644 --- a/src/mkapi/inspect/signature.py +++ b/src/mkapi/inspect/signature.py @@ -7,7 +7,7 @@ from mkapi.core import preprocess from mkapi.core.base import Inline, Item, Section, Type from mkapi.inspect.attribute import get_attributes -from mkapi.inspect.typing import to_string +from mkapi.inspect.typing import type_string @dataclass @@ -48,8 +48,8 @@ def __post_init__(self) -> None: self.parameters = Section("Parameters", items=items) self.set_attributes() return_type = self.signature.return_annotation - self.returns = to_string(return_type, kind="returns", obj=self.obj) - self.yields = to_string(return_type, kind="yields", obj=self.obj) + self.returns = type_string(return_type, kind="returns", obj=self.obj) + self.yields = type_string(return_type, kind="yields", obj=self.obj) def __contains__(self, name: str) -> bool: return name in self.parameters @@ -88,7 +88,7 @@ def set_attributes(self) -> None: """ items = [] for name, (tp, description) in get_attributes(self.obj).items(): - type_str = to_string(tp, obj=self.obj) if tp else "" + type_str = type_string(tp, obj=self.obj) if tp else "" if not type_str: type_str, description = preprocess.split_type(description) # noqa: PLW2901 @@ -122,16 +122,16 @@ def get_parameters(obj) -> tuple[list[Item], dict[str, Any]]: # noqa: ANN001 key = f"**{name}" else: key = name - type_ = to_string(parameter.annotation, obj=obj) + type_str = type_string(parameter.annotation, obj=obj) if (default := parameter.default) == inspect.Parameter.empty: defaults[key] = default else: defaults[key] = f"{default!r}" - if not type_: - type_ = "optional" - elif not type_.endswith(", optional"): - type_ += ", optional" - items.append(Item(key, Type(type_))) + if not type_str: + type_str = "optional" + elif not type_str.endswith(", optional"): + type_str += ", optional" + items.append(Item(key, Type(type_str))) return items, defaults diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index e8cc26a0..2e70fd53 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -1,13 +1,65 @@ """Type string.""" -import importlib import inspect -from types import UnionType +from types import EllipsisType, NoneType, UnionType from typing import ForwardRef, Union, get_args, get_origin from mkapi.core.link import get_link -def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001, PLR0911 +def type_string_none(tp: NoneType, obj: object) -> str: # noqa: D103, ARG001 + return "" + + +def type_string_ellipsis(tp: EllipsisType, obj: object) -> str: # noqa: D103, ARG001 + return "..." + + +def type_string_union(tp: UnionType, obj: object) -> str: # noqa: D103 + return " | ".join(type_string(arg, obj=obj) for arg in get_args(tp)) + + +def type_string_list(tp: list, obj: object) -> str: # noqa: D103 + args = ", ".join(type_string(arg, obj=obj) for arg in tp) + return f"[{args}]" + + +def type_string_str(tp: str, obj: object) -> str: + """Return a resolved name for `str`. + + Examples: + >>> from mkapi.core.base import Docstring + >>> type_string_str("Docstring", Docstring) + '[Docstring](!mkapi.core.base.Docstring)' + >>> type_string_str("invalid_object_name", Docstring) + 'invalid_object_name' + >>> type_string_str("no_module", 1) + 'no_module' + """ + if module := inspect.getmodule(obj): # noqa: SIM102 + if type_ := dict(inspect.getmembers(module)).get(tp): + return type_string(type_) + return tp + + +def type_string_forward_ref(tp: ForwardRef, obj: object) -> str: # noqa: D103 + return type_string_str(tp.__forward_arg__, obj) + + +TYPE_STRING_FUNCTIONS = { + NoneType: type_string_none, + EllipsisType: type_string_ellipsis, + UnionType: type_string_union, + list: type_string_list, + str: type_string_str, + ForwardRef: type_string_forward_ref, +} + + +def register_type_string_function(type_, func) -> None: # noqa: ANN001, D103 + TYPE_STRING_FUNCTIONS[type_] = func + + +def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001 """Return string expression for type. If possible, type string includes link. @@ -18,94 +70,41 @@ def to_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: obj: Object Examples: - >>> to_string(str) + >>> type_string(str) 'str' >>> from mkapi.core.node import Node - >>> to_string(Node) + >>> type_string(Node) '[Node](!mkapi.core.node.Node)' - >>> to_string(...) + >>> type_string(None) + '' + >>> type_string(...) '...' - >>> to_string([int, str]) + >>> type_string([int, str]) '[int, str]' - >>> to_string(int | str) + >>> type_string(int | str) 'int | str' + >>> type_string("Node", obj=Node) + '[Node](!mkapi.core.node.Node)' + >>> from typing import List + >>> type_string(List["Node"], obj=Node) + 'list[[Node](!mkapi.core.node.Node)]' + >>> from collections.abc import Iterable + >>> type_string(Iterable[int], kind="yields") + 'int' """ if kind == "yields": - return _to_string_for_yields(tp, obj) - if tp is None: - return "" - if tp == ...: - return "..." - if isinstance(tp, list): - args = ", ".join(to_string(arg, obj=obj) for arg in tp) - return f"[{args}]" - if isinstance(tp, UnionType): - return " | ".join(to_string(arg, obj=obj) for arg in get_args(tp)) - if isinstance(tp, str): - return resolve_forward_ref(tp, obj) - if isinstance(tp, ForwardRef): - return resolve_forward_ref(tp.__forward_arg__, obj) + return type_string_yields(tp, obj) + for type_, func in TYPE_STRING_FUNCTIONS.items(): + if isinstance(tp, type_): + return func(tp, obj) if isinstance(tp, type): return get_link(tp) if get_origin(tp): - return resolve_orign_args(tp, obj) + return type_string_origin_args(tp, obj) raise NotImplementedError - # if not hasattr(annotation, "__origin__"): - # return str(annotation).replace("typing.", "").lower() - # origin = annotation.__origin__ - # if origin is Union: - # return union(annotation, obj=obj) - # if origin is tuple: - # args = [to_string(x, obj=obj) for x in annotation.__args__] - # if args: - # return "(" + ", ".join(args) + ")" - # else: - # return "tuple" - # if origin is dict: - # if type(annotation.__args__[0]) == TypeVar: - # return "dict" - # args = [to_string(x, obj=obj) for x in annotation.__args__] - # if args: - # return "dict(" + ": ".join(args) + ")" - # else: - # return "dict" - # if not hasattr(annotation, "__args__"): - # return "" - # if len(annotation.__args__) == 0: - # return annotation.__origin__.__name__.lower() - # if len(annotation.__args__) == 1: - # return a_of_b(annotation, obj=obj) - # else: - # return to_string_args(annotation, obj=obj) - -def resolve_forward_ref(name: str, obj: object) -> str: - """Return a resolved name for `str` or `typing.ForwardRef`. - Args: - name: Forward reference name. - obj: Object - - Examples: - >>> from mkapi.core.base import Docstring - >>> resolve_forward_ref('Docstring', Docstring) - '[Docstring](!mkapi.core.base.Docstring)' - >>> resolve_forward_ref('invalid_object_name', Docstring) - 'invalid_object_name' - """ - if obj is None or not hasattr(obj, "__module__"): - return name - module = importlib.import_module(obj.__module__) - globals_ = dict(inspect.getmembers(module)) - try: - type_ = eval(name, globals_) - except NameError: - return name - else: - return to_string(type_) - - -def resolve_orign_args(tp, obj: object = None) -> str: # noqa: ANN001 +def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 """Return string expression for X[Y, Z, ...]. Args: @@ -113,156 +112,51 @@ def resolve_orign_args(tp, obj: object = None) -> str: # noqa: ANN001 obj: Object Examples: - >>> resolve_orign_args(list[str]) + >>> type_string_origin_args(list[str]) 'list[str]' >>> from typing import List, Tuple - >>> resolve_orign_args(List[Tuple[int, str]]) + >>> type_string_origin_args(List[Tuple[int, str]]) 'list[tuple[int, str]]' >>> from mkapi.core.node import Node - >>> resolve_orign_args(list[Node]) + >>> type_string_origin_args(list[Node]) 'list[[Node](!mkapi.core.node.Node)]' >>> from collections.abc import Callable, Iterator - >>> resolve_orign_args(Iterator[float]) + >>> type_string_origin_args(Iterator[float]) '[Iterator](!collections.abc.Iterator)[float]' - >>> resolve_orign_args(Callable[[], str]) + >>> type_string_origin_args(Callable[[], str]) '[Callable](!collections.abc.Callable)[[], str]' >>> from typing import Union, Optional - >>> resolve_orign_args(Union[int, str]) + >>> type_string_origin_args(Union[int, str]) 'int | str' - >>> resolve_orign_args(Optional[bool]) + >>> type_string_origin_args(Optional[bool]) 'bool | None' """ origin, args = get_origin(tp), get_args(tp) - args_list = [to_string(arg, obj=obj) for arg in args] + args_list = [type_string(arg, obj=obj) for arg in args] if origin is Union: if len(args) == 2 and args[1] == type(None): # noqa: PLR2004 return f"{args_list[0]} | None" return " | ".join(args_list) - origin_str = to_string(origin, obj=obj) + origin_str = type_string(origin, obj=obj) args_str = ", ".join(args_list) return f"{origin_str}[{args_str}]" -def _to_string_for_yields(tp, obj: object) -> str: # noqa: ANN001 - if hasattr(tp, "__args__") and tp.__args__: - if len(tp.__args__) == 1: - return to_string(tp.__args__[0], obj=obj) - return to_string(tp, obj=obj) - return "" - - -# def a_of_b(annotation, obj=None) -> str: -# """Return "A of B" style string. - -# Args: -# annotation: Annotation +def type_string_yields(tp, obj: object) -> str: # noqa: ANN001 + """Return string expression for type in generator. -# Examples: -# >>> from typing import List, Iterable, Iterator -# >>> a_of_b(List[str]) -# 'list of str' -# >>> a_of_b(List[List[str]]) -# 'list of list of str' -# >>> a_of_b(Iterable[int]) -# 'iterable of int' -# >>> a_of_b(Iterator[float]) -# 'iterator of float' -# """ -# origin = annotation.__origin__ -# if not hasattr(origin, "__name__"): -# return "" -# name = origin.__name__.lower() -# if type(annotation.__args__[0]) == TypeVar: -# return name -# type_ = f"{name} of " + to_string(annotation.__args__[0], obj=obj) -# if type_.endswith(" of T"): -# return name -# return type_ - - -# def union(annotation, obj=None) -> str: -# """Return a string for union annotation. - -# Args: -# annotation: Annotation - -# Examples: -# >>> from typing import List, Optional, Tuple, Union -# >>> union(Optional[List[str]]) -# 'list of str, optional' -# >>> union(Union[str, int]) -# 'str or int' -# >>> union(Union[str, int, float]) -# 'str, int, or float' -# >>> union(Union[List[str], Tuple[int, int]]) -# 'Union(list of str, (int, int))' -# """ -# args = annotation.__args__ -# if ( -# len(args) == 2 -# and hasattr(args[1], "__name__") -# and args[1].__name__ == "NoneType" -# ): -# return to_string(args[0], obj=obj) + ", optional" -# else: -# args = [to_string(x, obj=obj) for x in args] -# if all(" " not in arg for arg in args): -# if len(args) == 2: -# return " or ".join(args) -# else: -# return ", ".join(args[:-1]) + ", or " + args[-1] -# else: -# return "Union(" + ", ".join(to_string(x, obj=obj) for x in args) + ")" - - -# def to_string_args(annotation, obj: object = None) -> str: -# """Return a string for callable and generator annotation. - -# Args: -# annotation: Annotation - -# Examples: -# >>> from typing import Callable, List, Tuple, Any -# >>> from typing import Generator, AsyncGenerator -# >>> to_string_args(Callable[[int, List[str]], Tuple[int, int]]) -# 'callable(int, list of str: (int, int))' -# >>> to_string_args(Callable[[int], Any]) -# 'callable(int)' -# >>> to_string_args(Callable[[str], None]) -# 'callable(str)' -# >>> to_string_args(Callable[..., int]) -# 'callable(...: int)' -# >>> to_string_args(Generator[int, float, str]) -# 'generator(int, float, str)' -# >>> to_string_args(AsyncGenerator[int, float]) -# 'asyncgenerator(int, float)' -# """ - -# def to_string_with_prefix(annotation, prefix: str = ",") -> str: -# s = to_string(annotation, obj=obj) -# if s in ["NoneType", "any"]: -# return "" -# return f"{prefix} {s}" - -# args = annotation.__args__ -# name = annotation.__origin__.__name__.lower() -# if name == "callable": -# *args, returns = args -# args = ", ".join(to_string(x, obj=obj) for x in args) -# returns = to_string_with_prefix(returns, ":") -# return f"{name}({args}{returns})" -# if name == "generator": -# arg, sends, returns = args -# arg = to_string(arg, obj=obj) -# sends = to_string_with_prefix(sends) -# returns = to_string_with_prefix(returns) -# if not sends and returns: -# sends = "," -# return f"{name}({arg}{sends}{returns})" -# if name == "asyncgenerator": -# arg, sends = args -# arg = to_string(arg, obj=obj) -# sends = to_string_with_prefix(sends) -# return f"{name}({arg}{sends})" -# return "" + Examples: + >>> from collections.abc import Iterator + >>> type_string_yields(Iterator[int], None) + 'int' + >>> type_string_yields(int, None) # invalid type + '' + >>> type_string_yields(list[str, float], None) # invalid type + 'list[str, float]' + """ + if args := get_args(tp): + if len(args) == 1: + return type_string(args[0], obj=obj) + return type_string(tp, obj=obj) + return "" diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py index 7352a93d..93dbef6e 100644 --- a/tests/inspect/test_inspect_signature.py +++ b/tests/inspect/test_inspect_signature.py @@ -1,14 +1,8 @@ import inspect -from collections.abc import Callable import pytest -from mkapi.inspect.signature import ( - Signature, - get_parameters, - get_signature, - to_string, -) +from mkapi.inspect.signature import Signature, get_parameters, get_signature def test_get_parameters_error(): @@ -57,12 +51,6 @@ def test_dataclass(ExampleDataClass): # noqa: N803 assert s.attributes["y"].to_tuple()[1] == "int" -def test_to_string(): - assert to_string(list) == "list" - assert to_string(tuple) == "tuple" - assert to_string(dict) == "dict" - - def test_var(): def func(x, *args, **kwargs): return x, args, kwargs diff --git a/tests/inspect/test_inspect_typing.py b/tests/inspect/test_inspect_typing.py index e69de29b..d60c691f 100644 --- a/tests/inspect/test_inspect_typing.py +++ b/tests/inspect/test_inspect_typing.py @@ -0,0 +1,12 @@ +from collections.abc import Callable + +from mkapi.inspect.signature import Signature +from mkapi.inspect.typing import type_string + + +def test_to_string(): + assert type_string(list) == "list" + assert type_string(tuple) == "tuple" + assert type_string(dict) == "dict" + assert type_string(Callable) == "[Callable](!collections.abc.Callable)" + assert type_string(Signature) == "[Signature](!mkapi.inspect.signature.Signature)" From 06a06fa8a1f3688147b47bf00eab2fd08198c9a5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 12:31:42 +0900 Subject: [PATCH 010/148] attributes.py --- pyproject.toml | 2 +- src/mkapi/core/preprocess.py | 4 +- src/mkapi/inspect/attribute.py | 309 +++++++++++------------- tests/inspect/test_inspect_attribute.py | 66 ++--- 4 files changed, 173 insertions(+), 208 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f52b396f..7dc622ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] omit = ["src/mkapi/__about__.py"] [tool.coverage.report] -exclude_lines = ["no cov", "raise NotImplementedError"] +exclude_lines = ["no cov", "raise NotImplementedError", "if TYPE_CHECKING:"] [tool.hatch.envs.docs] dependencies = ["mkdocs", "mkdocs-material"] diff --git a/src/mkapi/core/preprocess.py b/src/mkapi/core/preprocess.py index f307e690..b780d2b7 100644 --- a/src/mkapi/core/preprocess.py +++ b/src/mkapi/core/preprocess.py @@ -52,7 +52,7 @@ def get_indent(line: str) -> int: def join_without_indent( - lines: list[str], + lines: list[str] | str, start: int = 0, stop: int | None = None, ) -> str: @@ -74,6 +74,8 @@ def join_without_indent( """ if not lines: return "" + if isinstance(lines, str): + return join_without_indent(lines.split("\n")) indent = get_indent(lines[start]) return "\n".join(line[indent:] for line in lines[start:stop]).strip() diff --git a/src/mkapi/inspect/attribute.py b/src/mkapi/inspect/attribute.py index ced1b9cf..ef2ca005 100644 --- a/src/mkapi/inspect/attribute.py +++ b/src/mkapi/inspect/attribute.py @@ -1,205 +1,158 @@ """Functions that inspect attributes from source code.""" from __future__ import annotations -import _ast import ast import dataclasses -import importlib import inspect from ast import AST from functools import lru_cache -from typing import TYPE_CHECKING, Any, Dict, List, Tuple, TypeGuard +from typing import TYPE_CHECKING, Any, TypeGuard from mkapi.core.preprocess import join_without_indent if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator from types import ModuleType from _typeshed import DataclassInstance -def parse_attribute(x) -> str: - return ".".join([parse_node(x.value), x.attr]) +def getsource_dedent(obj) -> str: # noqa: ANN001 + """Return the text of the source code for an object without exception.""" + try: + source = inspect.getsource(obj) + except OSError: + return "" + return join_without_indent(source) -def parse_attribute_with_lineno(x) -> tuple[str, int]: - return parse_node(x), x.lineno +def parse_attribute(node: ast.Attribute) -> str: # noqa: D103 + return ".".join([parse_node(node.value), node.attr]) -def parse_subscript(x) -> str: - value = parse_node(x.value) - slice = parse_node(x.slice) - if isinstance(slice, str): - return f"{value}[{slice}]" +def parse_subscript(node: ast.Subscript) -> str: # noqa: D103 + value = parse_node(node.value) + slice_ = parse_node(node.slice) + return f"{value}[{slice_}]" - type_str = ", ".join([str(elt) for elt in slice]) - return f"{value}[{type_str}]" +def parse_tuple(node: ast.Tuple) -> str: # noqa: D103 + return ", ".join(parse_node(n) for n in node.elts) -def parse_tuple(x): - return tuple(parse_node(x) for x in x.elts) + +def parse_list(node: ast.List) -> str: # noqa: D103 + return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" + + +PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ + (ast.Attribute, parse_attribute), + (ast.Subscript, parse_subscript), + (ast.Tuple, parse_tuple), + (ast.List, parse_list), + (ast.Name, "id"), + (ast.Constant, "value"), + (ast.Name, "value"), + (ast.Ellipsis, "value"), + (ast.Str, "value"), +] -def parse_list(x): - return "[" + ", ".join(parse_node(x) for x in x.elts) + "]" +def parse_node(node: AST) -> str: + """Return a string expression for AST node.""" + for type_, parse in PARSE_NODE_FUNCTIONS: + if isinstance(node, type_): + if callable(parse): + return parse(node) + return getattr(node, parse) + return ast.unparse(node) -def parse_node(x): - if isinstance(x, _ast.Name): - return x.id - elif isinstance(x, _ast.Assign): - return parse_node(x.targets[0]) - elif isinstance(x, _ast.Attribute): - return parse_attribute(x) - elif isinstance(x, _ast.Subscript): - return parse_subscript(x) - elif isinstance(x, _ast.Tuple): - return parse_tuple(x) - elif isinstance(x, _ast.List): - return parse_list(x) - elif hasattr(_ast, "Constant") and isinstance(x, _ast.Constant): - return x.value - elif hasattr(_ast, "Index") and isinstance(x, _ast.Index): - return x.value - elif hasattr(_ast, "Ellipsis") and isinstance(x, _ast.Ellipsis): - return x.value - elif hasattr(_ast, "Str") and isinstance(x, _ast.Str): - return x.s - else: - raise NotImplementedError +def get_attribute_list( + nodes: Iterable[AST], + module: ModuleType, + *, + is_module: bool = False, +) -> list[tuple[str, int, Any]]: + """Retrun list of tuple of (name, lineno, type).""" + attr_dict: dict[tuple[str, int], Any] = {} + linenos: dict[int, int] = {} + def update(name, lineno, type_=()) -> None: # noqa: ANN001 + if type_ or (name, lineno) not in attr_dict: + attr_dict[(name, lineno)] = type_ + linenos[lineno] = linenos.get(lineno, 0) + 1 -def parse_annotation_assign(assign) -> tuple[str, int, str]: - type = parse_node(assign.annotation) - attr, lineno = parse_attribute_with_lineno(assign.target) - return attr, lineno, type + members = dict(inspect.getmembers(module)) + for node in nodes: + if isinstance(node, ast.AnnAssign): + type_str = parse_node(node.annotation) + type_ = members.get(type_str, type_str) + update(parse_node(node.target), node.lineno, type_) + elif isinstance(node, ast.Attribute): # and isinstance(node.ctx, ast.Store): + update(parse_node(node), node.lineno) + elif is_module and isinstance(node, ast.Assign): + update(parse_node(node.targets[0]), node.lineno) + attrs = [(name, lineno, type_) for (name, lineno), type_ in attr_dict.items()] + attrs = [attr for attr in attrs if linenos[attr[1]] == 1] + return sorted(attrs, key=lambda attr: attr[1]) def get_description(lines: list[str], lineno: int) -> str: + """Return description from lines of source.""" index = lineno - 1 line = lines[index] - if " #: " in line: + if " #: " in (line := lines[lineno - 1]): return line.split(" #: ")[1].strip() - if index != 0: - line = lines[index - 1].strip() - if line.startswith("#: "): - return line[3:].strip() - if index + 1 < len(lines): - docs = [] - in_doc = False - for line in lines[index + 1 :]: - line = line.strip() - if not in_doc and not line: - break - elif not in_doc and (line.startswith("'''") or line.startswith('"""')): - mark = line[:3] + if lineno > 1 and (line := lines[lineno - 2].strip()).startswith("#: "): + return line[3:].strip() + if lineno < len(lines): + docs, in_doc, mark = [], False, "" + for line_ in lines[lineno:]: + line = line_.strip() + if in_doc: + if line.endswith(mark): + docs.append(line[:-3]) + return "\n".join(docs).strip() + docs.append(line) + elif line.startswith(("'''", '"""')): + in_doc, mark = True, line[:3] if line.endswith(mark): return line[3:-3] - in_doc = True docs.append(line[3:]) - elif in_doc and line.endswith(mark): - docs.append(line[:-3]) - return "\n".join(docs).strip() - elif in_doc: - docs.append(line) + elif not line: + return "" return "" -def get_source(obj) -> str: # noqa: ANN001 - """Return the text of the source code for an object without exception.""" - try: - return inspect.getsource(obj) - except OSError: - return "" - - -def get_attributes_with_lineno( - nodes: Iterable[AST], - module: ModuleType, - *, - is_module: bool = False, -) -> list[tuple[str, int, Any]]: - attr_dict: dict[tuple[str, int], Any] = {} - linenos: dict[int, int] = {} - - def update(attr, lineno, type): - if type or (attr, lineno) not in attr_dict: - attr_dict[(attr, lineno)] = type - linenos[lineno] = linenos.get(lineno, 0) + 1 - - globals = dict(inspect.getmembers(module)) - for x in nodes: - if isinstance(x, _ast.AnnAssign): - attr, lineno, type_str = parse_annotation_assign(x) - try: - type = eval(type_str, globals) - except NameError: - type = type_str - update(attr, lineno, type) - if isinstance(x, _ast.Attribute) and isinstance(x.ctx, _ast.Store): - attr, lineno = parse_attribute_with_lineno(x) - update(attr, lineno, ()) - if is_module and isinstance(x, _ast.Assign): - attr, lineno = parse_attribute_with_lineno(x) - update(attr, lineno, ()) - attr_lineno = [(attr, lineno, type) for (attr, lineno), type in attr_dict.items()] - attr_lineno = [x for x in attr_lineno if linenos[x[1]] == 1] - attr_lineno = sorted(attr_lineno, key=lambda x: x[1]) - return attr_lineno - - -def get_attributes_dict( +def get_attribute_dict( attr_list: list[tuple[str, int, Any]], source: str, prefix: str = "", ) -> dict[str, tuple[Any, str]]: + """Return an attribute dictionary.""" attrs: dict[str, tuple[Any, str]] = {} lines = source.split("\n") - for k, (name, lineno, type) in enumerate(attr_list): - if not prefix or name.startswith(prefix): - name = name[len(prefix) :] - stop = len(lines) - if k < len(attr_list) - 1: - stop = attr_list[k + 1][1] - 1 - description = get_description(lines[:stop], lineno) - if type: - attrs[name] = type, description # Assignment with type annotation wins. - elif name not in attrs: - attrs[name] = None, description + for k, (name, lineno, type_) in enumerate(attr_list): + if not name.startswith(prefix): + continue + name = name[len(prefix) :] # noqa: PLW2901 + stop = attr_list[k + 1][1] - 1 if k < len(attr_list) - 1 else len(lines) + description = get_description(lines[:stop], lineno) + if type_: + attrs[name] = (type_, description) # Assignment with type wins. + elif name not in attrs: + attrs[name] = None, description return attrs -def get_class_attributes(cls: type[Any]) -> dict[str, tuple[Any, str]]: - """Return a dictionary that maps attribute name to a tuple of (type, description). - - Args: - cls: Class object. - - Examples: - >>> from mkapi.core.base import Base - >>> attrs = get_class_attributes(Base) - >>> attrs["name"][0] is str - True - >>> attrs["name"][1] - 'Name of self.' - >>> attrs["callback"][0] - True - """ - source = get_source(cls) - if not source: - return {} - source = join_without_indent(source.split("\n")) - node = ast.parse(source) - nodes = ast.walk(node) - module = importlib.import_module(cls.__module__) - attr_lineno = get_attributes_with_lineno(nodes, module) - return get_attributes_dict(attr_lineno, source, prefix="self.") +def _nodeiter_before_function(node: AST) -> Iterator[AST]: + for x in ast.iter_child_nodes(node): + if isinstance(x, ast.FunctionDef): + break + yield x -def get_dataclass_attributes( - cls: type[DataclassInstance], -) -> dict[str, tuple[Any, str]]: +def get_dataclass_attributes(cls: type) -> dict[str, tuple[Any, str]]: """Return a dictionary that maps attribute name to a tuple of (type, description). Args: @@ -213,32 +166,41 @@ def get_dataclass_attributes( >>> attrs["description"][0] is Inline True """ - attrs = {} + source = getsource_dedent(cls) + module = inspect.getmodule(cls) + if not source or not module: + raise NotImplementedError # return {} + + node = ast.parse(source).body[0] + nodes = _nodeiter_before_function(node) + attr_lineno = get_attribute_list(nodes, module) + attr_dict = get_attribute_dict(attr_lineno, source) + + attrs: dict[str, tuple[Any, str]] = {} for field in dataclasses.fields(cls): if field.type != dataclasses.InitVar: attrs[field.name] = field.type, "" + for name, (type_, description) in attr_dict.items(): + attrs[name] = attrs.get(name, [type_])[0], description + + return attrs - source = get_source(cls) - source = join_without_indent(source.split("\n")) - if not source: - return {} - node = ast.parse(source).body[0] - def nodes() -> Iterator[AST]: - for x in ast.iter_child_nodes(node): - if isinstance(x, _ast.FunctionDef): - break - yield x +def get_class_attributes(cls: type) -> dict[str, tuple[Any, str]]: + """Return a dictionary that maps attribute name to a tuple of (type, description). - module = importlib.import_module(cls.__module__) - attr_lineno = get_attributes_with_lineno(nodes(), module) - for name, (type_, description) in get_attributes_dict(attr_lineno, source).items(): - if name in attrs: - attrs[name] = attrs[name][0], description - else: - attrs[name] = type_, description + Args: + cls: Class object. + """ + source = getsource_dedent(cls) + module = inspect.getmodule(cls) + if not source or not module: + raise NotImplementedError # return {} - return attrs + node = ast.parse(source) + nodes = ast.walk(node) + attr_lineno = get_attribute_list(nodes, module) + return get_attribute_dict(attr_lineno, source, prefix="self.") def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: @@ -253,13 +215,14 @@ def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: >>> attrs["renderer"][0] is renderer.Renderer True """ - source = get_source(module) + source = getsource_dedent(module) if not source: - return {} + raise NotImplementedError # return {} + node = ast.parse(source) nodes = ast.iter_child_nodes(node) - attr_lineno = get_attributes_with_lineno(nodes, module, is_module=True) - return get_attributes_dict(attr_lineno, source) + attr_lineno = get_attribute_list(nodes, module, is_module=True) + return get_attribute_dict(attr_lineno, source) def isdataclass(obj: object) -> TypeGuard[type[DataclassInstance]]: @@ -287,4 +250,4 @@ def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: for is_, get in ATTRIBUTES_FUNCTIONS: if is_(obj): return get(obj) - return {} + raise NotImplementedError # return {} diff --git a/tests/inspect/test_inspect_attribute.py b/tests/inspect/test_inspect_attribute.py index 70b4fb36..8b06334c 100644 --- a/tests/inspect/test_inspect_attribute.py +++ b/tests/inspect/test_inspect_attribute.py @@ -1,8 +1,16 @@ from dataclasses import dataclass from examples.styles import google -from mkapi.inspect import signature -from mkapi.inspect.attribute import get_attributes, get_description +from mkapi.core.base import Base +from mkapi.inspect.attribute import get_attributes, get_description, getsource_dedent +from mkapi.inspect.typing import type_string + + +def test_getsource_dedent(): + src = getsource_dedent(Base) + assert src.startswith("@dataclass\nclass Base:\n") + src = getsource_dedent(google.ExampleClass) + assert src.startswith("class ExampleClass:\n") class A: @@ -22,20 +30,20 @@ def __init__(self): def test_class_attribute(): attrs = get_attributes(A) - assert attrs for k, (name, (type_, markdown)) in enumerate(attrs.items()): assert name == ["x", "y", "a", "z"][k] assert markdown.startswith(["Doc ", "list of", "", "Docstring *after*"][k]) assert markdown.endswith(["attribute.", "specified.", "", "supported."][k]) if k == 0: - assert type_ is int - elif k == 1: + assert type_ == "int" + if k == 1: assert type_ is None - elif k == 2: + if k == 2: assert not markdown - elif k == 3: - x = signature.to_string(type_) - assert x == "(list of int, dict(str: list of float))" + if k == 3: + x = type_string(type_) + assert x == "tuple[list[int], dict[str, list[float]]]" + assert ".\n\nM" in markdown class B: @@ -62,6 +70,9 @@ class C: end. """ + def func(self): + pass + def test_dataclass_attribute(): attrs = get_attributes(C) @@ -84,18 +95,18 @@ def test_dataclass_attribute_without_desc(): assert name == ["x", "y"][k] assert markdown == "" if k == 0: - assert type is int - elif k == 1: - x = signature.to_string(type_) - assert x == "list of str" + assert type_ is int + if k == 1: + x = type_string(type_) + assert x == "list[str]" def test_module_attribute(): - attrs = get_attributes(google) + attrs = get_attributes(google) # type:ignore for k, (name, (type_, markdown)) in enumerate(attrs.items()): if k == 0: assert name == "first_attribute" - assert type_ is int + assert type_ == "int" assert markdown.startswith("The first module level attribute.") if k == 1: assert name == "second_attribute" @@ -103,7 +114,7 @@ def test_module_attribute(): assert markdown.startswith("str: The second module level attribute.") if k == 2: assert name == "third_attribute" - assert signature.to_string(type_) == "list of int" + assert type_string(type_) == "list[int]" assert markdown.startswith("The third module level attribute.") assert markdown.endswith("supported.") @@ -116,30 +127,19 @@ def test_one_line_docstring(): def test_module_attribute_tye(): from mkapi.core import renderer - assert get_attributes(renderer)["renderer"][0] is renderer.Renderer + assert get_attributes(renderer)["renderer"][0] is renderer.Renderer # type: ignore class E: def __init__(self): - self.a: int = 0 #: a - self.b: str = "b" #: b + self.a: int = 0 #: attr-a + self.b: "E" = self #: attr-b def func(self): - self.a, self.b = 1, "x" + pass def test_multiple_assignments(): attrs = get_attributes(E) - assert attrs["a"] == (int, "a") - assert attrs["b"] == (str, "b") - - -def test_name_error(): - abc = "abc" - - class Name: - def __init__(self): - self.x: abc = 1 #: abc. - - attrs = get_attributes(Name) - assert attrs["x"] == ("abc", "abc.") + assert attrs["a"] == ("int", "attr-a") + assert attrs["b"] == (E, "attr-b") From fc990cc9332a481a7c8dfca05cfd371c79aab100 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 13:09:56 +0900 Subject: [PATCH 011/148] inspect module --- src/mkapi/inspect/attribute.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mkapi/inspect/attribute.py b/src/mkapi/inspect/attribute.py index ef2ca005..094381f8 100644 --- a/src/mkapi/inspect/attribute.py +++ b/src/mkapi/inspect/attribute.py @@ -169,7 +169,7 @@ def get_dataclass_attributes(cls: type) -> dict[str, tuple[Any, str]]: source = getsource_dedent(cls) module = inspect.getmodule(cls) if not source or not module: - raise NotImplementedError # return {} + return {} node = ast.parse(source).body[0] nodes = _nodeiter_before_function(node) @@ -195,7 +195,7 @@ def get_class_attributes(cls: type) -> dict[str, tuple[Any, str]]: source = getsource_dedent(cls) module = inspect.getmodule(cls) if not source or not module: - raise NotImplementedError # return {} + return {} node = ast.parse(source) nodes = ast.walk(node) @@ -217,7 +217,7 @@ def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: """ source = getsource_dedent(module) if not source: - raise NotImplementedError # return {} + return {} node = ast.parse(source) nodes = ast.iter_child_nodes(node) @@ -250,4 +250,4 @@ def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: for is_, get in ATTRIBUTES_FUNCTIONS: if is_(obj): return get(obj) - raise NotImplementedError # return {} + return {} From 3f18dc3e42a7af8dc297288cde6ee06707f44a0d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 13:36:05 +0900 Subject: [PATCH 012/148] object.py --- pyproject.toml | 3 ++ src/mkapi/core/object.py | 70 +++++++++++++++------------------------- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7dc622ad..c11c5cf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,3 +111,6 @@ unfixable = [ [tool.ruff.isort] known-first-party = ["mkapi"] + +[tool.ruff.pydocstyle] +convention = "google" diff --git a/src/mkapi/core/object.py b/src/mkapi/core/object.py index acab74b4..0965eb01 100644 --- a/src/mkapi/core/object.py +++ b/src/mkapi/core/object.py @@ -2,18 +2,16 @@ import abc import importlib import inspect -from typing import Any, List, Tuple +from typing import Any -def get_object(name: str) -> Any: +def get_object(name: str) -> Any: # noqa: ANN401 """Reutrns an object specified by `name`. Args: - ---- name: Object name. Examples: - -------- >>> import inspect >>> obj = get_object('mkapi.core') >>> inspect.ismodule(obj) @@ -38,36 +36,31 @@ def get_object(name: str) -> Any: for attr in names[k:]: obj = getattr(obj, attr) return obj - raise ValueError(f"Could not find object: {name}") + msg = f"Could not find object: {name}" + raise ValueError(msg) -def get_fullname(obj: Any, name: str) -> str: +def get_fullname(obj: object, name: str) -> str: """Reutrns an object full name specified by `name`. Args: - ---- obj: Object that has a module. name: Object name in the module. Examples: - -------- - >>> obj = get_object('mkapi.core.base.Item') - >>> get_fullname(obj, 'Section') + >>> obj = get_object("mkapi.core.base.Item") + >>> get_fullname(obj, "Section") 'mkapi.core.base.Section' - >>> get_fullname(obj, 'preprocess') - 'mkapi.core.preprocess' + >>> get_fullname(obj, "add_fence") + 'mkapi.core.preprocess.add_fence' >>> get_fullname(obj, 'abc') '' """ - if not hasattr(obj, "__module__"): - return "" - obj = importlib.import_module(obj.__module__) - names = name.split(".") - - for name in names: - if not hasattr(obj, name): + obj = inspect.getmodule(obj) + for name_ in name.split("."): + if not hasattr(obj, name_): return "" - obj = getattr(obj, name) + obj = getattr(obj, name_) if isinstance(obj, property): return "" @@ -75,15 +68,13 @@ def get_fullname(obj: Any, name: str) -> str: return ".".join(split_prefix_and_name(obj)) -def split_prefix_and_name(obj: Any) -> tuple[str, str]: +def split_prefix_and_name(obj: object) -> tuple[str, str]: """Splits an object full name into prefix and name. Args: - ---- obj: Object that has a module. Examples: - -------- >>> import inspect >>> obj = get_object('mkapi.core') >>> split_prefix_and_name(obj) @@ -107,32 +98,34 @@ def split_prefix_and_name(obj: Any) -> tuple[str, str]: prefix, name = module, qualname else: prefix, _, name = qualname.rpartition(".") - prefix = ".".join([module, prefix]) + prefix = f"{module}.{prefix}" if prefix == "__main__": prefix = "" return prefix, name -def get_qualname(obj: Any): +def get_qualname(obj: object) -> str: + """Return `qualname`.""" if hasattr(obj, "__qualname__"): return obj.__qualname__ return "" -def get_sourcefile_and_lineno(obj: Any) -> tuple[str, int]: +def get_sourcefile_and_lineno(obj: type) -> tuple[str, int]: + """Return source file and line number.""" try: sourcefile = inspect.getsourcefile(obj) or "" except TypeError: sourcefile = "" try: - lineno = inspect.getsourcelines(obj)[1] + _, lineno = inspect.getsourcelines(obj) except (TypeError, OSError): lineno = -1 return sourcefile, lineno # Issue#19 (metaclass). TypeError: descriptor 'mro' of 'type' object needs an argument. -def get_mro(obj): +def get_mro(obj: Any) -> list[type]: # noqa: D103, ANN401 try: objs = obj.mro()[:-1] # drop ['object'] except TypeError: @@ -142,19 +135,15 @@ def get_mro(obj): return objs -def get_sourcefiles(obj: Any) -> list[str]: +def get_sourcefiles(obj: type) -> list[str]: """Returns a list of source file. If `obj` is a class, source files of its superclasses are also included. Args: - ---- obj: Object name. """ - if inspect.isclass(obj) and hasattr(obj, "mro"): - objs = get_mro(obj) - else: - objs = [obj] + objs = get_mro(obj) if inspect.isclass(obj) and hasattr(obj, "mro") else [obj] sourfiles = [] for obj in objs: try: @@ -167,16 +156,14 @@ def get_sourcefiles(obj: Any) -> list[str]: return sourfiles -def from_object(obj: Any) -> bool: +def from_object(obj: object) -> bool: """Returns True, if the docstring of `obj` is the same as that of `object`. Args: - ---- name: Object name. obj: Object. Examples: - -------- >>> class A: pass >>> from_object(A.__call__) True @@ -193,11 +180,10 @@ def from_object(obj: Any) -> bool: return inspect.getdoc(obj) == getattr(object, name).__doc__ -def get_origin(obj: Any) -> Any: +def get_origin(obj): # noqa: ANN001, ANN201 """Returns an original object. - Examples - -------- + Examples: >>> class A: ... @property ... def x(self): @@ -211,10 +197,6 @@ def get_origin(obj: Any) -> Any: return get_origin(obj.fget) if not callable(obj): return obj - # if hasattr(obj, "__wrapped__"): - # return get_origin(obj.__wrapped__) - # if hasattr(obj, "__pytest_wrapped__"): - # return get_origin(obj.__pytest_wrapped__.obj) try: wrapped = obj.__wrapped__ except AttributeError: From e3e12955d49b09cc21d75b54e802cf7727a95bb2 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 13:47:17 +0900 Subject: [PATCH 013/148] inherit.py --- src/mkapi/core/inherit.py | 25 ++++++++++--------------- tests/core/test_core_inherit.py | 5 +++-- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/mkapi/core/inherit.py b/src/mkapi/core/inherit.py index f3013cf5..5354c22f 100644 --- a/src/mkapi/core/inherit.py +++ b/src/mkapi/core/inherit.py @@ -1,5 +1,5 @@ """This module implements the functionality of docstring inheritance.""" -from typing import Iterator, Tuple +from collections.abc import Iterator from mkapi.core.base import Section from mkapi.core.node import Node, get_node @@ -106,7 +106,7 @@ def inherit_base(node: Node, base: Node, name: str = "both"): node.docstring.set_section(section, replace=True) -def get_bases(node: Node) -> Iterator[Tuple[Node, Iterator[Node]]]: +def get_bases(node: Node) -> Iterator[tuple[Node, Iterator[Node]]]: """Yields a tuple of (Node instance, iterator of Node). Args: @@ -114,25 +114,20 @@ def get_bases(node: Node) -> Iterator[Tuple[Node, Iterator[Node]]]: Examples: >>> from mkapi.core.object import get_object - >>> node = Node(get_object('mkapi.core.base.Type')) + >>> node = Node(get_object("mkapi.core.base.Type")) >>> it = get_bases(node) >>> n, gen = next(it) >>> n is node True >>> [x.object.name for x in gen] ['Inline', 'Base'] - >>> for n, gen in it: - ... if n.object.name == 'set_html': - ... break - >>> [x.object.name for x in gen] - ['set_html', 'set_html'] """ bases = get_mro(node.obj)[1:] yield node, (get_node(base) for base in bases) for member in node.members: name = member.object.name - def gen(name=name): + def gen(name: str = name) -> Iterator[Node]: for base in bases: if hasattr(base, name): obj = getattr(base, name) @@ -142,18 +137,18 @@ def gen(name=name): yield member, gen() -def inherit(node: Node): +def inherit(node: Node) -> None: """Inherits Parameters and Attributes from superclasses. Args: node: Node instance. - """ + """ if node.object.kind not in ["class", "dataclass"]: return - for node, bases in get_bases(node): - if is_complete(node): + for node_, bases in get_bases(node): + if is_complete(node_): continue for base in bases: - inherit_base(node, base) - if is_complete(node): + inherit_base(node_, base) + if is_complete(node_): break diff --git a/tests/core/test_core_inherit.py b/tests/core/test_core_inherit.py index b6b728db..eef201da 100644 --- a/tests/core/test_core_inherit.py +++ b/tests/core/test_core_inherit.py @@ -13,12 +13,13 @@ def test_is_complete(): @pytest.mark.parametrize( - "name, mode", product(["Parameters", "Attributes"], ["Docstring", "Signature"]) + ("name", "mode"), + product(["Parameters", "Attributes"], ["Docstring", "Signature"]), ) def test_get_section(name, mode): def func(): pass section = get_section(Node(func), name, mode) - section.name == name + assert section.name == name assert not section From 94d84bfd77182feccacffcfdc39084aa39dcd3a4 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 15:43:07 +0900 Subject: [PATCH 014/148] structure.py --- src/mkapi/core/structure.py | 62 ++++++++++++++----------------------- 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/src/mkapi/core/structure.py b/src/mkapi/core/structure.py index cdccceab..0264b584 100644 --- a/src/mkapi/core/structure.py +++ b/src/mkapi/core/structure.py @@ -1,9 +1,7 @@ -"""This module provides base class of [Node](mkapi.core.node.Node) and -[Module](mkapi.core.module.Module). -""" +"""Base class of [Node](mkapi.core.node.Node) and [Module](mkapi.core.module.Module).""" from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Any, Union +from typing import Any, Self from mkapi.core.base import Base, Type from mkapi.core.docstring import Docstring, get_docstring @@ -15,15 +13,12 @@ ) from mkapi.inspect.signature import Signature, get_signature -"a.b.c".rpartition(".") - @dataclass class Object(Base): """Object class represents an object. Args: - ---- name: Object name. prefix: Object prefix. qualname: Qualified name. @@ -31,7 +26,6 @@ class Object(Base): signature: Signature if object is module or callable. Attributes: - ---------- id: ID attribute of HTML. type: Type for missing Returns and Yields sections. """ @@ -42,15 +36,15 @@ class Object(Base): signature: Signature = field(default_factory=Signature) module: str = field(init=False) markdown: str = field(init=False) - id: str = field(init=False) - type: Type = field(default_factory=Type, init=False) + id: str = field(init=False) # noqa: A003 + type: Type = field(default_factory=Type, init=False) # noqa: A003 - def __post_init__(self): + def __post_init__(self) -> None: from mkapi.core import link self.id = self.name if self.prefix: - self.id = ".".join([self.prefix, self.name]) + self.id = f"{self.prefix}.{self.name}" if not self.qualname: self.module = self.id else: @@ -59,14 +53,13 @@ def __post_init__(self): name = link.link(self.name, self.id) if self.prefix: prefix = link.link(self.prefix, self.prefix) - self.markdown = ".".join([prefix, name]) + self.markdown = f"{prefix}.{name}" else: self.markdown = name - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ - id = self.id - return f"{class_name}({id!r})" + return f"{class_name}({self.id!r})" def __iter__(self) -> Iterator[Base]: yield from self.type @@ -75,15 +68,12 @@ def __iter__(self) -> Iterator[Base]: @dataclass class Tree: - """Tree class. This class is the base class of [Node](mkapi.core.node.Node) - and [Module](mkapi.core.module.Module). + """Base of [Node](mkapi.core.node.Node) and [Module](mkapi.core.module.Module). Args: - ---- obj: Object. Attributes: - ---------- sourcefile: Source file path. lineno: Line number. object: Object instance. @@ -95,12 +85,12 @@ class Tree: obj: Any = field() sourcefile: str = field(init=False) lineno: int = field(init=False) - object: Object = field(init=False) + object: Object = field(init=False) # noqa: A003 docstring: Docstring = field(init=False) parent: Any = field(default=None, init=False) - members: list[Any] = field(init=False) + members: list[Self] = field(init=False) - def __post_init__(self): + def __post_init__(self) -> None: obj = get_origin(self.obj) self.sourcefile, self.lineno = get_sourcefile_and_lineno(obj) prefix, name = split_prefix_and_name(obj) @@ -120,21 +110,20 @@ def __post_init__(self): for member in self.members: member.parent = self - def __repr__(self): + def __repr__(self) -> str: class_name = self.__class__.__name__ - id = self.object.id + id_ = self.object.id sections = len(self.docstring.sections) numbers = len(self.members) - return f"{class_name}({id!r}, num_sections={sections}, num_members={numbers})" + return f"{class_name}({id_!r}, num_sections={sections}, num_members={numbers})" - def __getitem__(self, index: Union[int, str, list[str]]): - """Returns a member {class} instance. + def __getitem__(self, index: int | str | list[str]) -> Self: + """Return a member {class} instance. If `index` is str, a member Tree instance whose name is equal to `index` is returned. - Raises - ------ + Raises: IndexError: If no member found. """ if isinstance(index, list): @@ -152,20 +141,17 @@ def __getitem__(self, index: Union[int, str, list[str]]): return member raise IndexError - def __len__(self): + def __len__(self) -> int: return len(self.members) - def __contains__(self, name): - for member in self.members: - if member.object.name == name: - return True - return False + def __contains__(self, name: str) -> bool: + return any(member.object.name == name for member in self.members) def get_kind(self) -> str: """Returns kind of self.""" raise NotImplementedError - def get_members(self) -> list["Tree"]: + def get_members(self) -> list[Self]: """Returns a list of members.""" raise NotImplementedError @@ -173,7 +159,7 @@ def get_markdown(self) -> str: """Returns a Markdown source for docstring of self.""" raise NotImplementedError - def walk(self) -> Iterator["Tree"]: + def walk(self) -> Iterator[Self]: """Yields all members.""" yield self for member in self.members: From c238d5f02edc8979cfd5589984c09ce9b883e56b Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 15:47:20 +0900 Subject: [PATCH 015/148] test_link --- tests/core/{test_core_linker.py => test_core_link.py} | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) rename tests/core/{test_core_linker.py => test_core_link.py} (88%) diff --git a/tests/core/test_core_linker.py b/tests/core/test_core_link.py similarity index 88% rename from tests/core/test_core_linker.py rename to tests/core/test_core_link.py index d1e1afe0..8fb3d4c3 100644 --- a/tests/core/test_core_linker.py +++ b/tests/core/test_core_link.py @@ -6,11 +6,11 @@ class A: def func(self): pass - def _private(): + def _private(self): pass q = "test_get_link_private..A" - m = "test_core_linker" + m = "test_core_link" assert link.get_link(A) == f"[{q}](!{m}.{q})" assert link.get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" assert link.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" @@ -23,10 +23,6 @@ def test_resolve_link(): assert link.resolve_link("[A](a)", "", []) == "[A](a)" -def test_resolve_href(): - assert link.resolve_href("", "", []) == "" - - def test_resolve_object(): html = "

p

" context = link.resolve_object(html) From 65937bbe77593630362dfec91913a310ce2e000a Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 15:49:00 +0900 Subject: [PATCH 016/148] test_link noqa --- tests/core/test_core_link.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_core_link.py b/tests/core/test_core_link.py index 8fb3d4c3..df6e81af 100644 --- a/tests/core/test_core_link.py +++ b/tests/core/test_core_link.py @@ -13,8 +13,8 @@ def _private(self): m = "test_core_link" assert link.get_link(A) == f"[{q}](!{m}.{q})" assert link.get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" - assert link.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" - assert link.get_link(A._private) == f"{q}._private" + assert link.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore + assert link.get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 def test_resolve_link(): From 619e6a87d6d5c4c28b35bc36679a08fb8814ca22 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 15:52:53 +0900 Subject: [PATCH 017/148] module-node --- src/mkapi/core/module.py | 61 ++++++------ src/mkapi/core/node.py | 184 +++++++++++++++++++------------------ src/mkapi/core/object.py | 4 +- src/mkapi/core/renderer.py | 2 +- 4 files changed, 127 insertions(+), 124 deletions(-) diff --git a/src/mkapi/core/module.py b/src/mkapi/core/module.py index 2b82967d..a2c11be3 100644 --- a/src/mkapi/core/module.py +++ b/src/mkapi/core/module.py @@ -1,12 +1,13 @@ """This modules provides Module class that has tree structure.""" import inspect import os +from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Dict, Iterator, List, Optional +from pathlib import Path +from typing import Optional -from mkapi.core.node import Node +from mkapi.core.node import Node, get_node from mkapi.core.node import get_members as get_node_members -from mkapi.core.node import get_node from mkapi.core.object import get_object from mkapi.core.structure import Tree @@ -22,15 +23,15 @@ class Module(Tree): """ parent: Optional["Module"] = field(default=None, init=False) - members: List["Module"] = field(init=False) + members: list["Module"] = field(init=False) node: Node = field(init=False) - def __post_init__(self): + def __post_init__(self) -> None: super().__post_init__() self.node = get_node(self.obj) def __iter__(self) -> Iterator["Module"]: - if self.docstring: + if self.docstring: # noqa: SIM114 yield self elif self.object.kind in ["package", "module"] and any( m.docstring for m in self.members @@ -40,20 +41,18 @@ def __iter__(self) -> Iterator["Module"]: for member in self.members: yield from member - def get_kind(self) -> str: + def get_kind(self) -> str: # noqa: D102 if not self.sourcefile or self.sourcefile.endswith("__init__.py"): return "package" - else: - return "module" + return "module" - def get_members(self) -> List: + def get_members(self) -> list: # noqa: D102 if self.object.kind == "module": return get_node_members(self.obj) - else: - return get_members(self.obj) + return get_members(self.obj) - def get_markdown(self, filters: List[str]) -> str: # type:ignore - """Returns a Markdown source for docstring of this object. + def get_markdown(self, filters: list[str]) -> str: + """Return a Markdown source for docstring of this object. Args: filters: A list of filters. Avaiable filters: `upper`, `inherit`, @@ -61,28 +60,29 @@ def get_markdown(self, filters: List[str]) -> str: # type:ignore """ from mkapi.core.renderer import renderer - return renderer.render_module(self, filters) # type:ignore + return renderer.render_module(self, filters) -def get_members(obj) -> List[Module]: +def get_members(obj: object) -> list[Module]: + """Return members.""" try: - sourcefile = inspect.getsourcefile(obj) + sourcefile = inspect.getsourcefile(obj) # type: ignore # noqa: PGH003 except TypeError: return [] if not sourcefile: return [] - root = os.path.dirname(sourcefile) + root = Path(sourcefile).parent paths = [path for path in os.listdir(root) if not path.startswith("_")] members = [] for path in paths: - root_ = os.path.join(root, path) + root_ = root / path name = "" - if os.path.isdir(root_) and "__init__.py" in os.listdir(root_): + if Path.is_dir(root_) and "__init__.py" in os.listdir(root_): name = path elif path.endswith(".py"): name = path[:-3] if name: - name = ".".join([obj.__name__, name]) + name = f"{obj.__name__}.{name}" module = get_module(name) members.append(module) packages = [] @@ -95,24 +95,19 @@ def get_members(obj) -> List[Module]: return modules + packages -modules: Dict[str, Module] = {} +modules: dict[str, Module] = {} -def get_module(name) -> Module: - """Returns a Module instace by name or object. +def get_module(name: str) -> Module: + """Return a Module instace by name or object. Args: name: Object name or object itself. """ - if isinstance(name, str): - obj = get_object(name) - else: - obj = name - + obj = get_object(name) if isinstance(name, str) else name name = obj.__name__ if name in modules: return modules[name] - else: - module = Module(obj) - modules[name] = module - return module + module = Module(obj) + modules[name] = module + return module diff --git a/src/mkapi/core/node.py b/src/mkapi/core/node.py index e1890bf3..34ffdb68 100644 --- a/src/mkapi/core/node.py +++ b/src/mkapi/core/node.py @@ -1,13 +1,15 @@ -"""This modules provides Node class that has tree structure.""" +"""Node class that has tree structure.""" import inspect +from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from typing import Any, Callable, Iterator, List, Optional +from types import FunctionType +from typing import Optional from mkapi.core import preprocess from mkapi.core.base import Base, Type -from mkapi.core.object import (from_object, get_object, get_origin, - get_sourcefiles) +from mkapi.core.object import from_object, get_object, get_origin, get_sourcefiles from mkapi.core.structure import Object, Tree +from mkapi.inspect.attribute import isdataclass @dataclass(repr=False) @@ -24,10 +26,10 @@ class Node(Tree): """ parent: Optional["Node"] = field(default=None, init=False) - members: List["Node"] = field(init=False) + members: list["Node"] = field(init=False) sourcefile_index: int = 0 - def __post_init__(self): + def __post_init__(self) -> None: super().__post_init__() members = self.members @@ -40,13 +42,13 @@ def __post_init__(self): self.members = [m for m in members if m.object.name != "__init__"] doc = self.docstring - if doc and "property" in self.object.kind: + if doc and "property" in self.object.kind: # noqa: SIM102 if not doc.type and len(doc.sections) == 1 and doc.sections[0].name == "": section = doc.sections[0] markdown = section.markdown - type, markdown = preprocess.split_type(markdown) - if type: - doc.type = Type(type) + type_, markdown = preprocess.split_type(markdown) + if type_: + doc.type = Type(type_) section.markdown = markdown if doc and doc.type: @@ -60,31 +62,29 @@ def __iter__(self) -> Iterator[Base]: yield from member def get_kind(self) -> str: + """Return node kind.""" if inspect.ismodule(self.obj): if self.sourcefile.endswith("__init__.py"): return "package" - else: - return "module" - abstract = is_abstract(self.obj) + return "module" if isinstance(self.obj, property): - if self.obj.fset: - kind = "readwrite property" - else: - kind = "readonly property" + kind = "readwrite property" if self.obj.fset else "readonly property" else: kind = get_kind(get_origin(self.obj)) - if abstract: + if is_abstract(self.obj): return "abstract " + kind - else: - return kind + return kind - def get_members(self) -> List["Node"]: # type:ignore + def get_members(self) -> list["Node"]: + """Return members.""" return get_members(self.obj) def get_markdown( - self, level: int = 0, callback: Optional[Callable[[Base], str]] = None + self, + level: int = 0, + callback: Callable[[Base], str] | None = None, ) -> str: - """Returns a Markdown source for docstring of this object. + """Return a Markdown source for docstring of this object. Args: level: Heading level. If 0, `
` tags are used. @@ -94,10 +94,7 @@ def get_markdown( member_objects = [member.object for member in self.members] class_name = "" for base in self: - if callback: - markdown = callback(base) - else: - markdown = base.markdown + markdown = callback(base) if callback else base.markdown markdown = markdown.replace("{class}", class_name) if isinstance(base, Object): if level: @@ -110,61 +107,80 @@ def get_markdown( markdowns.append(markdown) return "\n\n\n\n".join(markdowns) - def set_html(self, html: str): - """Sets HTML to [Base]() instances recursively. + def set_html(self, html: str) -> None: + """Set HTML to [Base]() instances recursively. Args: html: HTML that is provided by a Markdown converter. """ - for base, html in zip(self, html.split("")): - base.set_html(html.strip()) + for base, html_ in zip(self, html.split(""), strict=False): + base.set_html(html_.strip()) - def get_html(self, filters: List[str] = None) -> str: - """Renders and returns HTML.""" + def get_html(self, filters: list[str] | None = None) -> str: + """Render and return HTML.""" from mkapi.core.renderer import renderer - return renderer.render(self, filters) # type:ignore + return renderer.render(self, filters) -def is_abstract(obj) -> bool: +def is_abstract(obj: object) -> bool: + """Return true if `obj` is abstract.""" if inspect.isabstract(obj): return True - if hasattr(obj, "__isabstractmethod__") and obj.__isabstractmethod__: + if hasattr(obj, "__isabstractmethod__") and obj.__isabstractmethod__: # type: ignore # noqa: PGH003 return True - else: + return False + + +def has_self(obj: object) -> bool: + """Return true if `obj` has `__self__`.""" + try: + return hasattr(obj, "__self__") + except KeyError: return False -def get_kind(obj) -> str: - try: # KeyError on __dataclass_field__ (Issue#13). - if hasattr(obj, "__dataclass_fields__") and hasattr(obj, "__qualname__"): - return "dataclass" - if hasattr(obj, "__self__"): - if type(obj.__self__) is type or type(type(obj.__self__)): # Issue#18 - return "classmethod" - except Exception: - pass - if inspect.isclass(obj): - return "class" - if inspect.isgeneratorfunction(obj): - return "generator" - if inspect.isfunction(obj): - try: - parameters = inspect.signature(obj).parameters - except (ValueError, TypeError): - return "" - if parameters: - arg = list(parameters)[0] - if arg == "self": - return "method" - if hasattr(obj, "__qualname__") and "." in obj.__qualname__: - return "staticmethod" - return "function" +def get_kind_self(obj: object) -> str: # noqa: D103 + try: + self = obj.__self__ # type: ignore # noqa: PGH003 + except KeyError: + return "" + if isinstance(self, type) or type(type(self)): # Issue#18 + return "classmethod" + return "" + + +def get_kind_function(obj: FunctionType) -> str: # noqa: D103 + try: + parameters = inspect.signature(obj).parameters + except (ValueError, TypeError): + return "" + if parameters and next(iter(parameters)) == "self": + return "method" + if hasattr(obj, "__qualname__") and "." in obj.__qualname__: + return "staticmethod" + return "function" + + +KIND_FUNCTIONS: list[tuple[Callable[..., bool], str | Callable[..., str]]] = [ + (isdataclass, "dataclass"), + (inspect.isclass, "class"), + (inspect.isgeneratorfunction, "generator"), + (has_self, get_kind_self), + (inspect.isfunction, get_kind_function), +] + + +def get_kind(obj: object) -> str: + """Return kind of object.""" + for func, kind in KIND_FUNCTIONS: + if func(obj) and (kind_ := kind if isinstance(kind, str) else kind(obj)): + return kind_ return "" -def is_member(obj: Any, name: str = "", sourcefiles: List[str] = None) -> int: - """Returns an integer thats indicates if `obj` is a member or not. +def is_member(obj: object, name: str = "", sourcefiles: list[str] | None = None) -> int: # noqa: PLR0911 + """Return an integer thats indicates if `obj` is a member or not. * $-1$ : Is not a member. * $>0$ : Is a member. If the value is larger than 0, `obj` is defined @@ -178,18 +194,17 @@ def is_member(obj: Any, name: str = "", sourcefiles: List[str] = None) -> int: those of the superclasses should be included in the order of `mro()`. """ - if name == "": - name = obj.__name__ + name = name or obj.__name__ obj = get_origin(obj) if name in ["__func__", "__self__", "__base__", "__bases__"]: return -1 - if name.startswith("_"): + if name.startswith("_"): # noqa: SIM102 if not name.startswith("__") or not name.endswith("__"): return -1 if not get_kind(obj): return -1 try: - sourcefile = inspect.getsourcefile(obj) + sourcefile = inspect.getsourcefile(obj) # type: ignore # noqa: PGH003 except TypeError: return -1 if not sourcefiles: @@ -200,41 +215,34 @@ def is_member(obj: Any, name: str = "", sourcefiles: List[str] = None) -> int: return -1 -def get_members(obj: Any) -> List[Node]: +def get_members(obj: object) -> list[Node]: + """Return members.""" sourcefiles = get_sourcefiles(obj) members = [] - for name, obj in inspect.getmembers(obj): - sourcefile_index = is_member(obj, name, sourcefiles) - if sourcefile_index != -1 and not from_object(obj): - member = get_node(obj, sourcefile_index) + for name, obj_ in inspect.getmembers(obj): + sourcefile_index = is_member(obj_, name, sourcefiles) + if sourcefile_index != -1 and not from_object(obj_): + member = get_node(obj_, sourcefile_index) if member.docstring: members.append(member) return sorted(members, key=lambda x: (-x.sourcefile_index, x.lineno)) -def get_node(name, sourcefile_index: int = 0) -> Node: - """Returns a Node instace by name or object. +def get_node(name: str | object, sourcefile_index: int = 0) -> Node: + """Return a Node instace by name or object. Args: name: Object name or object itself. sourcefile_index: If `obj` is a member of class, this value is the index of unique source files given by `mro()` of the class. Otherwise, 0. """ - - if isinstance(name, str): - obj = get_object(name) - else: - obj = name - + obj = get_object(name) if isinstance(name, str) else name return Node(obj, sourcefile_index) -def get_node_from_module(name): +def get_node_from_module(name: str | object) -> None: + """Return a Node instace by name or object from `modules` dict.""" from mkapi.core.module import modules - if isinstance(name, str): - obj = get_object(name) - else: - obj = name - - return modules[obj.__module__].node[obj.__qualname__] + obj = get_object(name) if isinstance(name, str) else name + return modules[obj.__module__].node[obj.__qualname__] # type: ignore # noqa: PGH003 diff --git a/src/mkapi/core/object.py b/src/mkapi/core/object.py index 0965eb01..30fcb7a8 100644 --- a/src/mkapi/core/object.py +++ b/src/mkapi/core/object.py @@ -135,7 +135,7 @@ def get_mro(obj: Any) -> list[type]: # noqa: D103, ANN401 return objs -def get_sourcefiles(obj: type) -> list[str]: +def get_sourcefiles(obj: object) -> list[str]: """Returns a list of source file. If `obj` is a class, source files of its superclasses are also included. @@ -147,7 +147,7 @@ def get_sourcefiles(obj: type) -> list[str]: sourfiles = [] for obj in objs: try: - sourcefile = inspect.getsourcefile(obj) or "" + sourcefile = inspect.getsourcefile(obj) or "" # type: ignore # noqa: PGH003 except TypeError: pass else: diff --git a/src/mkapi/core/renderer.py b/src/mkapi/core/renderer.py index 318a0045..86c29d64 100644 --- a/src/mkapi/core/renderer.py +++ b/src/mkapi/core/renderer.py @@ -36,7 +36,7 @@ def __post_init__(self): name = os.path.splitext(name)[0] self.templates[name] = template - def render(self, node: Node, filters: list[str] = None) -> str: + def render(self, node: Node, filters: list[str] | None = None) -> str: """Returns a rendered HTML for Node. Args: From a3a27f8d95ae070d4ca7e429c0a90c25ec9f9f63 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 16:04:13 +0900 Subject: [PATCH 018/148] test_node --- tests/core/test_core_node.py | 38 +++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/core/test_core_node.py b/tests/core/test_core_node.py index 29ab41a1..ae074da1 100644 --- a/tests/core/test_core_node.py +++ b/tests/core/test_core_node.py @@ -3,42 +3,43 @@ def test_generator(): - node = get_node("google_style.gen") + node = get_node("examples.styles.google.gen") assert node.object.kind == "generator" def test_class(): - node = get_node("google_style.ExampleClass") - assert node.object.prefix == "google_style" + node = get_node("examples.styles.google.ExampleClass") + assert node.object.prefix == "examples.styles.google" assert node.object.name == "ExampleClass" assert node.object.kind == "class" assert len(node) == 3 p = node.members[-1] - assert p.object.type.name == "list of int" + assert p.object.type.name == "list[int]" assert p.docstring.sections[0].markdown.startswith("Read-write property") def test_dataclass(): - node = get_node("google_style.ExampleDataClass") - assert node.object.prefix == "google_style" + node = get_node("examples.styles.google.ExampleDataClass") + assert node.object.prefix == "examples.styles.google" assert node.object.name == "ExampleDataClass" assert node.object.kind == "dataclass" def test_is_member_private(): class A: - def _private(): + def _private(self): pass - def func(): + def func(self): pass class B(A): - def _private(): + def _private(self): pass - assert is_member(A._private) == -1 + assert is_member(A._private) == -1 # noqa: SLF001 assert is_member(A.func) == 0 + assert is_member(B.func) == 0 def test_is_member_source_file_index(): @@ -61,7 +62,7 @@ def test_get_markdown(): x = "[mkapi.core.base](!mkapi.core.base).[Base](!mkapi.core.base.Base)" assert parts[0] == "## " + x - def callback(base): + def callback(_): return "123" markdown = node.get_markdown(callback=callback) @@ -124,22 +125,22 @@ def test_get_node_from_module(): def test_get_markdown_bases(): - node = get_node("examples.appendix.inherit.Sub") + node = get_node("examples.cls.inherit.Sub") markdown = node.get_markdown() parts = [x.strip() for x in markdown.split("")] - x = "[examples.appendix.inherit.Base]" + x = "[examples.cls.inherit.Base]" assert parts[1].startswith(x) def test_set_html_and_render_bases(): - node = get_node("examples.appendix.inherit.Sub") + node = get_node("examples.cls.inherit.Sub") markdown = node.get_markdown() sep = "" n = len(markdown.split(sep)) html = sep.join(str(x) for x in range(n)) node.set_html(html) html = node.get_html() - assert "mkapi-section-bases" + assert "mkapi-section bases" in html def test_decorated_member(): @@ -183,6 +184,7 @@ def test_short_filter(): n = len(markdown.split(sep)) html = sep.join(str(x) for x in range(n)) node.set_html(html) - h = node.get_html(filters=["short"]) - assert '"mkapi-object-body dataclass top">Base' in h - assert "prefix" not in h + html = node.get_html(filters=["short"]) + sub = '"mkapi-object-body dataclass top">Base' + assert sub in html + assert "prefix" not in html From a6600b266a64475864bc3111ff7d82a1c78ad222 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 16:21:28 +0900 Subject: [PATCH 019/148] test_object --- tests/core/test_core_object.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/core/test_core_object.py diff --git a/tests/core/test_core_object.py b/tests/core/test_core_object.py new file mode 100644 index 00000000..d6572cb7 --- /dev/null +++ b/tests/core/test_core_object.py @@ -0,0 +1,35 @@ +from mkapi.core.node import Node +from mkapi.core.object import ( + get_object, + get_origin, + get_qualname, + get_sourcefile_and_lineno, + split_prefix_and_name, +) + + +def test_get_object(): + obj = get_object("mkapi.core.node.Node") + assert obj is Node + + +def test_get_origin(): + obj = get_object("mkapi.core.node.Node") + org = get_origin(obj) + assert org is Node + + +def test_get_sourcefile_and_lineno(): + sourcefile, lineno = get_sourcefile_and_lineno(Node) + assert sourcefile.endswith("node.py") + assert lineno == 15 + + +def test_split_prefix_and_name(): + prefix, name = split_prefix_and_name(Node) + assert prefix == "mkapi.core.node" + assert name == "Node" + + +def test_qualname(): + assert get_qualname(Node) == "Node" From 10cccd317e4e1138bb1796f30e7b280068489813 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 17:16:44 +0900 Subject: [PATCH 020/148] type_string: fallback -> get_link --- src/mkapi/inspect/typing.py | 4 +--- tests/core/test_core_link.py | 26 +++++++++++++++---------- tests/inspect/test_inspect_signature.py | 9 +++++++++ tests/inspect/test_inspect_typing.py | 4 +++- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index 2e70fd53..b042d147 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -97,11 +97,9 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq for type_, func in TYPE_STRING_FUNCTIONS.items(): if isinstance(tp, type_): return func(tp, obj) - if isinstance(tp, type): - return get_link(tp) if get_origin(tp): return type_string_origin_args(tp, obj) - raise NotImplementedError + return get_link(tp) def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 diff --git a/tests/core/test_core_link.py b/tests/core/test_core_link.py index df6e81af..abed6fa1 100644 --- a/tests/core/test_core_link.py +++ b/tests/core/test_core_link.py @@ -1,4 +1,6 @@ -from mkapi.core import link +import typing + +from mkapi.core.link import get_link, resolve_link, resolve_object def test_get_link_private(): @@ -11,22 +13,26 @@ def _private(self): q = "test_get_link_private..A" m = "test_core_link" - assert link.get_link(A) == f"[{q}](!{m}.{q})" - assert link.get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" - assert link.get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore - assert link.get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 + assert get_link(A) == f"[{q}](!{m}.{q})" + assert get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" + assert get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore + assert get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 + + +def test_get_link_typing(): + assert get_link(typing.Self) == "[Self](!typing.Self)" def test_resolve_link(): - assert link.resolve_link("[A](!!a)", "", []) == "[A](a)" - assert link.resolve_link("[A](!a)", "", []) == "A" - assert link.resolve_link("[A](a)", "", []) == "[A](a)" + assert resolve_link("[A](!!a)", "", []) == "[A](a)" + assert resolve_link("[A](!a)", "", []) == "A" + assert resolve_link("[A](a)", "", []) == "[A](a)" def test_resolve_object(): html = "

p

" - context = link.resolve_object(html) + context = resolve_object(html) assert context == {"heading_id": "", "level": 0, "prefix_url": "", "name_url": "a"} html = "

pn

" - context = link.resolve_object(html) + context = resolve_object(html) assert context == {"heading_id": "", "level": 0, "prefix_url": "a", "name_url": "b"} diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py index 93dbef6e..901e4f7f 100644 --- a/tests/inspect/test_inspect_signature.py +++ b/tests/inspect/test_inspect_signature.py @@ -2,6 +2,7 @@ import pytest +from mkapi.core.node import Node from mkapi.inspect.signature import Signature, get_parameters, get_signature @@ -58,3 +59,11 @@ def func(x, *args, **kwargs): s = get_signature(func) assert s.parameters.items[1].name == "*args" assert s.parameters.items[2].name == "**kwargs" + + +def test_get_signature_special(): + s = Signature(Node.__getitem__) + assert s.parameters.items[0].name == "index" + assert s.returns == "[Self](!typing.Self)" + t = "int | str | list[str]" + assert s.parameters["index"].to_tuple() == ("index", t, "") diff --git a/tests/inspect/test_inspect_typing.py b/tests/inspect/test_inspect_typing.py index d60c691f..902c4e49 100644 --- a/tests/inspect/test_inspect_typing.py +++ b/tests/inspect/test_inspect_typing.py @@ -1,12 +1,14 @@ from collections.abc import Callable +from typing import Self from mkapi.inspect.signature import Signature from mkapi.inspect.typing import type_string -def test_to_string(): +def test_type_string(): assert type_string(list) == "list" assert type_string(tuple) == "tuple" assert type_string(dict) == "dict" assert type_string(Callable) == "[Callable](!collections.abc.Callable)" assert type_string(Signature) == "[Signature](!mkapi.inspect.signature.Signature)" + assert type_string(Self) == "[Self](!typing.Self)" From 78a4792dd4684631b2ac385b8a41c13af05946d5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 18:29:53 +0900 Subject: [PATCH 021/148] module.py --- src/mkapi/core/link.py | 5 ++++- src/mkapi/inspect/attribute.py | 20 +++++++++++++------- src/mkapi/inspect/typing.py | 12 ++++++++++-- tests/core/test_core_node.py | 11 ++++------- tests/inspect/test_inspect_signature.py | 1 + 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/mkapi/core/link.py b/src/mkapi/core/link.py index df4aa0a7..8e93cedd 100644 --- a/src/mkapi/core/link.py +++ b/src/mkapi/core/link.py @@ -1,6 +1,7 @@ """Provide functions that relate to linking functionality.""" import os import re +import warnings from html.parser import HTMLParser from pathlib import Path from typing import Any @@ -46,7 +47,9 @@ def get_link(obj: type, *, include_module: bool = False) -> str: name = obj.__name__ else: msg = f"obj has no name: {obj}" - raise ValueError(msg) + warnings.warn(msg, stacklevel=1) + return str(obj) + if not hasattr(obj, "__module__") or (module := obj.__module__) == "builtins": return name fullname = f"{module}.{name}" diff --git a/src/mkapi/inspect/attribute.py b/src/mkapi/inspect/attribute.py index 094381f8..0921224f 100644 --- a/src/mkapi/inspect/attribute.py +++ b/src/mkapi/inspect/attribute.py @@ -4,6 +4,7 @@ import ast import dataclasses import inspect +import warnings from ast import AST from functools import lru_cache from typing import TYPE_CHECKING, Any, TypeGuard @@ -36,6 +37,15 @@ def parse_subscript(node: ast.Subscript) -> str: # noqa: D103 return f"{value}[{slice_}]" +def parse_constant(node: ast.Constant) -> str: # noqa: D103 + value = node.value + if value is Ellipsis: + return "..." + if not isinstance(value, str): + warnings.warn("Not `str`", stacklevel=1) + return value + + def parse_tuple(node: ast.Tuple) -> str: # noqa: D103 return ", ".join(parse_node(n) for n in node.elts) @@ -47,13 +57,10 @@ def parse_list(node: ast.List) -> str: # noqa: D103 PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ (ast.Attribute, parse_attribute), (ast.Subscript, parse_subscript), + (ast.Constant, parse_constant), (ast.Tuple, parse_tuple), (ast.List, parse_list), (ast.Name, "id"), - (ast.Constant, "value"), - (ast.Name, "value"), - (ast.Ellipsis, "value"), - (ast.Str, "value"), ] @@ -61,9 +68,8 @@ def parse_node(node: AST) -> str: """Return a string expression for AST node.""" for type_, parse in PARSE_NODE_FUNCTIONS: if isinstance(node, type_): - if callable(parse): - return parse(node) - return getattr(node, parse) + node_str = parse(node) if callable(parse) else getattr(node, parse) + return node_str if isinstance(node_str, str) else str(node_str) return ast.unparse(node) diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index b042d147..e41d48ec 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -1,5 +1,6 @@ """Type string.""" import inspect +from dataclasses import InitVar from types import EllipsisType, NoneType, UnionType from typing import ForwardRef, Union, get_args, get_origin @@ -45,6 +46,10 @@ def type_string_forward_ref(tp: ForwardRef, obj: object) -> str: # noqa: D103 return type_string_str(tp.__forward_arg__, obj) +def type_string_init_var(tp: InitVar, obj: object) -> str: # noqa: D103 + return type_string(tp.type, obj=obj) + + TYPE_STRING_FUNCTIONS = { NoneType: type_string_none, EllipsisType: type_string_ellipsis, @@ -52,6 +57,7 @@ def type_string_forward_ref(tp: ForwardRef, obj: object) -> str: # noqa: D103 list: type_string_list, str: type_string_str, ForwardRef: type_string_forward_ref, + InitVar: type_string_init_var, } @@ -85,12 +91,15 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq 'int | str' >>> type_string("Node", obj=Node) '[Node](!mkapi.core.node.Node)' - >>> from typing import List + >>> from typing import List, get_origin >>> type_string(List["Node"], obj=Node) 'list[[Node](!mkapi.core.node.Node)]' >>> from collections.abc import Iterable >>> type_string(Iterable[int], kind="yields") 'int' + >>> from dataclasses import InitVar + >>> type_string(InitVar[Node]) + '[Node](!mkapi.core.node.Node)' """ if kind == "yields": return type_string_yields(tp, obj) @@ -128,7 +137,6 @@ def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 'int | str' >>> type_string_origin_args(Optional[bool]) 'bool | None' - """ origin, args = get_origin(tp), get_args(tp) args_list = [type_string(arg, obj=obj) for arg in args] diff --git a/tests/core/test_core_node.py b/tests/core/test_core_node.py index ae074da1..4f3f8baa 100644 --- a/tests/core/test_core_node.py +++ b/tests/core/test_core_node.py @@ -1,5 +1,7 @@ from mkapi.core.module import get_module from mkapi.core.node import get_kind, get_node, get_node_from_module, is_member +from mkapi.inspect import attribute +from mkapi.inspect.signature import Signature def test_generator(): @@ -144,17 +146,12 @@ def test_set_html_and_render_bases(): def test_decorated_member(): - from mkapi.inspect import attribute - from mkapi.inspect.signature import Signature - node = get_node(attribute) assert node.members[-1].object.kind == "function" assert get_node(Signature)["arguments"].object.kind == "readonly property" -def test_colon_in_docstring(): - """Issue#17""" - +def test_colon_in_docstring(): # Issue #17 class A: def func(self): """this: is not type.""" @@ -163,7 +160,7 @@ def func(self): def prop(self): """this: is type.""" - def func(self): + def func(self): # noqa: ARG001 """this: is not type.""" node = get_node(A) diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py index 901e4f7f..88b9b591 100644 --- a/tests/inspect/test_inspect_signature.py +++ b/tests/inspect/test_inspect_signature.py @@ -3,6 +3,7 @@ import pytest from mkapi.core.node import Node +from mkapi.core.page import Page from mkapi.inspect.signature import Signature, get_parameters, get_signature From 87a6ba6150feb4bd5a6c8e2ad17686f076c57a1c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 20:28:18 +0900 Subject: [PATCH 022/148] node.py: is_member --- src/mkapi/core/docstring.py | 15 ++++++--------- src/mkapi/core/node.py | 11 +++++++---- src/mkapi/inspect/typing.py | 2 ++ tests/core/test_core_node.py | 10 +++++++++- tests/core/test_core_postprocess.py | 23 +++++++++++------------ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/mkapi/core/docstring.py b/src/mkapi/core/docstring.py index f6800648..07880542 100644 --- a/src/mkapi/core/docstring.py +++ b/src/mkapi/core/docstring.py @@ -209,7 +209,7 @@ def parse_bases(doc: Docstring, obj: Any): def parse_source(doc: Docstring, obj: Any): """Parses parameters' docstring to inspect type and description from source. - Examples + Examples: -------- >>> from mkapi.core.base import Base >>> doc = Docstring() @@ -240,7 +240,7 @@ def parse_source(doc: Docstring, obj: Any): doc[name].set_item(item) -def postprocess(doc: Docstring, obj: Any): +def postprocess(doc: Docstring, obj: object): parse_bases(doc, obj) parse_source(doc, obj) if not callable(obj): @@ -282,20 +282,17 @@ def postprocess(doc: Docstring, obj: Any): if sections and sections[-1].name == "": sections[-1].markdown += "\n\n" + markdown continue - else: - section.name = "" - section.markdown = markdown + section.name = "" + section.markdown = markdown sections.append(section) doc.sections = sections -def get_docstring(obj: Any) -> Docstring: +def get_docstring(obj: object) -> Docstring: """Returns a [Docstring]() instance.""" doc = inspect.getdoc(obj) if doc: - sections = [] - for section in split_section(doc): - sections.append(get_section(*section)) + sections = [get_section(*section) for section in split_section(doc)] docstring = Docstring(sections) else: return Docstring() diff --git a/src/mkapi/core/node.py b/src/mkapi/core/node.py index 34ffdb68..f789adb3 100644 --- a/src/mkapi/core/node.py +++ b/src/mkapi/core/node.py @@ -2,6 +2,7 @@ import inspect from collections.abc import Callable, Iterator from dataclasses import dataclass, field +from pathlib import Path from types import FunctionType from typing import Optional @@ -32,14 +33,13 @@ class Node(Tree): def __post_init__(self) -> None: super().__post_init__() - members = self.members if self.object.kind in ["class", "dataclass"] and not self.docstring: - for member in members: + for member in self.members: if member.object.name == "__init__" and member.docstring: markdown = member.docstring.sections[0].markdown if not markdown.startswith("Initialize self"): self.docstring = member.docstring - self.members = [m for m in members if m.object.name != "__init__"] + self.members = [m for m in self.members if m.object.name != "__init__"] doc = self.docstring if doc and "property" in self.object.kind: # noqa: SIM102 @@ -207,10 +207,13 @@ def is_member(obj: object, name: str = "", sourcefiles: list[str] | None = None) sourcefile = inspect.getsourcefile(obj) # type: ignore # noqa: PGH003 except TypeError: return -1 + if not sourcefile: + return -1 if not sourcefiles: return 0 + sourcefile_path = Path(sourcefile) for sourcefile_index, parent_sourcefile in enumerate(sourcefiles): - if sourcefile == parent_sourcefile: + if Path(parent_sourcefile) == sourcefile_path: return sourcefile_index return -1 diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index e41d48ec..fc85abb4 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -103,6 +103,8 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq """ if kind == "yields": return type_string_yields(tp, obj) + if tp == inspect.Parameter.empty: + return "" for type_, func in TYPE_STRING_FUNCTIONS.items(): if isinstance(tp, type_): return func(tp, obj) diff --git a/tests/core/test_core_node.py b/tests/core/test_core_node.py index 4f3f8baa..a6e48dec 100644 --- a/tests/core/test_core_node.py +++ b/tests/core/test_core_node.py @@ -1,5 +1,5 @@ from mkapi.core.module import get_module -from mkapi.core.node import get_kind, get_node, get_node_from_module, is_member +from mkapi.core.node import Node, get_kind, get_node, get_node_from_module, is_member from mkapi.inspect import attribute from mkapi.inspect.signature import Signature @@ -27,6 +27,14 @@ def test_dataclass(): assert node.object.kind == "dataclass" +def test_node_object_type(): + class A: + """AAA""" + + node = Node(A) + assert node.object.type.name == "" + + def test_is_member_private(): class A: def _private(self): diff --git a/tests/core/test_core_postprocess.py b/tests/core/test_core_postprocess.py index aa992ff0..6d12e7c9 100644 --- a/tests/core/test_core_postprocess.py +++ b/tests/core/test_core_postprocess.py @@ -1,4 +1,4 @@ -from typing import Iterator, Tuple +from collections.abc import Iterator import pytest @@ -13,10 +13,10 @@ class A: def p(self): """ppp""" - def f(self) -> str: + def f(self) -> str: # type: ignore """fff""" - def g(self) -> int: + def g(self) -> int: # type: ignore """ggg aaa @@ -25,7 +25,7 @@ def g(self) -> int: value. """ - def a(self) -> Tuple[int, str]: + def a(self) -> tuple[int, str]: # type: ignore """aaa""" def b(self) -> Iterator[str]: @@ -36,20 +36,19 @@ class B: """BBB""" -@pytest.fixture +@pytest.fixture() def node(): - node = Node(A) - return node + return Node(A) -def test_transform_property(node): +def test_transform_property(node: Node): P.transform_property(node) section = node.docstring["Attributes"] assert "p" in section assert "f" in node -def test_get_type(node): +def test_get_type(node: Node): assert P.get_type(node).name == "" assert P.get_type(node["f"]).name == "str" assert P.get_type(node["g"]).name == "int" @@ -58,7 +57,7 @@ def test_get_type(node): node["g"].docstring.sections[1] -def test_transform_class(node): +def test_transform_class(node: Node): P.transform(node) section = node.docstring["Methods"] q = A.__qualname__ @@ -90,7 +89,7 @@ def test_transform_module(module): def test_link_from_toc(): - from examples.google_style import ExampleClass + from examples.styles.google import ExampleClass node = Node(ExampleClass) assert len(node.docstring.sections) == 4 @@ -99,4 +98,4 @@ def test_link_from_toc(): assert "Methods" in node.docstring section = node.docstring["Methods"] html = section.items[0].html - assert '' in html + assert '' in html From d6ea3209e06c1d20eb637cf58cc7dae47b99639c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 21:44:44 +0900 Subject: [PATCH 023/148] docstring.py --- src/mkapi/core/docstring.py | 199 ++++++++++++++++----------------- tests/core/test_core_object.py | 3 +- 2 files changed, 96 insertions(+), 106 deletions(-) diff --git a/src/mkapi/core/docstring.py b/src/mkapi/core/docstring.py index 07880542..801ea3dd 100644 --- a/src/mkapi/core/docstring.py +++ b/src/mkapi/core/docstring.py @@ -1,16 +1,14 @@ -"""This module provides functions that parse docstring.""" - +"""Parse docstring.""" import inspect import re from collections.abc import Iterator from dataclasses import is_dataclass -from typing import Any from mkapi.core.base import Docstring, Inline, Item, Section, Type from mkapi.core.link import get_link, replace_link from mkapi.core.object import get_mro from mkapi.core.preprocess import add_admonition, get_indent, join_without_indent -from mkapi.inspect.signature import get_signature +from mkapi.inspect.signature import Signature, get_signature SECTIONS = [ "Args", @@ -34,7 +32,7 @@ ] -def rename_section(name: str) -> str: +def rename_section(name: str) -> str: # noqa: D103 if name in ["Args", "Arguments"]: return "Parameters" if name == "Warns": @@ -43,14 +41,12 @@ def rename_section(name: str) -> str: def section_heading(line: str) -> tuple[str, str]: - """Returns a tuple of (section name, style name). + """Return a tuple of (section name, style name). Args: - ---- line: Docstring line. Examples: - -------- >>> section_heading("Args:") ('Args', 'google') >>> section_heading("Raises") @@ -60,50 +56,40 @@ def section_heading(line: str) -> tuple[str, str]: """ if line in SECTIONS: return line, "numpy" - elif line.endswith(":") and line[:-1] in SECTIONS: + if line.endswith(":") and line[:-1] in SECTIONS: return line[:-1], "google" - else: - return "", "" + return "", "" def split_section(doc: str) -> Iterator[tuple[str, str, str]]: - """Yields a tuple of (section name, contents, style). + r"""Yield a tuple of (section name, contents, style). Args: - ---- doc: Docstring Examples: - -------- - >>> doc = "abc\\n\\nArgs:\\n x: X\\n" + >>> doc = "abc\n\nArgs:\n x: X\n" >>> it = split_section(doc) >>> next(it) ('', 'abc', '') >>> next(it) ('Parameters', 'x: X', 'google') """ - lines = [x.rstrip() for x in doc.split("\n")] - name = "" - style = "" + name = style = "" start = indent = 0 + lines = [x.rstrip() for x in doc.split("\n")] for stop, line in enumerate(lines, 1): - if stop == len(lines): - next_indent = -1 - else: - next_indent = get_indent(lines[stop]) + next_indent = -1 if stop == len(lines) else get_indent(lines[stop]) if not line and next_indent < indent and name: if start < stop - 1: yield name, join_without_indent(lines[start : stop - 1]), style - start = stop - name = "" + start, name = stop, "" else: section, style_ = section_heading(line) if section: if start < stop - 1: yield name, join_without_indent(lines[start : stop - 1]), style - style = style_ - name = rename_section(section) - start = stop + style, start, name = style_, stop, rename_section(section) if style == "numpy": # skip underline without counting the length. start += 1 indent = next_indent @@ -112,125 +98,116 @@ def split_section(doc: str) -> Iterator[tuple[str, str, str]]: def split_parameter(doc: str) -> Iterator[list[str]]: - """Yields a list of parameter string. + """Yield a list of parameter string. Args: - ---- doc: Docstring """ - lines = [x.rstrip() for x in doc.split("\n")] start = stop = 0 - for stop, line in enumerate(lines, 1): - if stop == len(lines): - next_indent = 0 - else: - next_indent = get_indent(lines[stop]) + lines = [x.rstrip() for x in doc.split("\n")] + for stop, _ in enumerate(lines, 1): + next_indent = 0 if stop == len(lines) else get_indent(lines[stop]) if next_indent == 0: yield lines[start:stop] start = stop +PARAMETER_PATTERN = { + "google": re.compile(r"(.*?)\s*?\((.*?)\)"), + "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), +} + + def parse_parameter(lines: list[str], style: str) -> Item: - """Returns a Item instance that represents a parameter. + """Return a Item instance corresponding to a parameter. Args: - ---- lines: Splitted parameter docstring lines. style: Docstring style. `google` or `numpy`. """ if style == "google": name, _, line = lines[0].partition(":") - name = name.strip() - parsed = [line.strip()] - pattern = r"(.*?)\s*?\((.*?)\)" + name, parsed = name.strip(), [line.strip()] else: - name = lines[0].strip() - parsed = [] - pattern = r"([^ ]*?)\s*:\s*(.*)" + name, parsed = lines[0].strip(), [] if len(lines) > 1: indent = get_indent(lines[1]) for line in lines[1:]: parsed.append(line[indent:]) - m = re.match(pattern, name) - if m: - name, type = m.group(1), m.group(2) + if m := re.match(PARAMETER_PATTERN[style], name): + name, type_ = m.group(1), m.group(2) else: - type = "" - return Item(name, Type(type), Inline("\n".join(parsed))) + type_ = "" + return Item(name, Type(type_), Inline("\n".join(parsed))) def parse_parameters(doc: str, style: str) -> list[Item]: - """Returns a list of Item.""" + """Return a list of Item.""" return [parse_parameter(lines, style) for lines in split_parameter(doc)] def parse_returns(doc: str, style: str) -> tuple[str, str]: - """Returns a tuple of (type, markdown).""" - lines = doc.split("\n") + """Return a tuple of (type, markdown).""" + type_, lines = "", doc.split("\n") if style == "google": if ":" in lines[0]: - type, _, lines[0] = lines[0].partition(":") - type = type.strip() + type_, _, lines[0] = lines[0].partition(":") + type_ = type_.strip() lines[0] = lines[0].strip() - else: - type = "" else: - type = lines[0].strip() - lines = lines[1:] - return type, join_without_indent(lines) + type_, lines = lines[0].strip(), lines[1:] + return type_, join_without_indent(lines) def get_section(name: str, doc: str, style: str) -> Section: - """Returns a [Section]() instance.""" - type = "" - markdown = "" + """Return a [Section]() instance.""" + type_ = markdown = "" items = [] if name in ["Parameters", "Attributes", "Raises"]: items = parse_parameters(doc, style) elif name in ["Returns", "Yields"]: - type, markdown = parse_returns(doc, style) + type_, markdown = parse_returns(doc, style) else: markdown = doc - return Section(name, markdown, items, Type(type)) + return Section(name, markdown, items, Type(type_)) -def parse_bases(doc: Docstring, obj: Any): - """Parses base classes to create a Base(s) line.""" +def parse_bases(doc: Docstring, obj: object) -> None: + """Parse base classes to create a Base(s) line.""" if not inspect.isclass(obj) or not hasattr(obj, "mro"): return objs = get_mro(obj)[1:] if not objs: return - types = [get_link(obj, include_module=True) for obj in objs] - items = [Item(type=Type(type)) for type in types if type] + types = [get_link(obj_, include_module=True) for obj_ in objs] + items = [Item(type=Type(type_)) for type_ in types if type_] doc.set_section(Section("Bases", items=items)) -def parse_source(doc: Docstring, obj: Any): - """Parses parameters' docstring to inspect type and description from source. +def parse_source(doc: Docstring, obj: object) -> None: + """Parse parameters' docstring to inspect type and description from source. Examples: - -------- >>> from mkapi.core.base import Base >>> doc = Docstring() >>> parse_source(doc, Base) - >>> section = doc['Parameters'] - >>> section['name'].to_tuple() + >>> section = doc["Parameters"] + >>> section["name"].to_tuple() ('name', 'str, optional', 'Name of self.') - >>> section = doc['Attributes'] - >>> section['html'].to_tuple() + >>> section = doc["Attributes"] + >>> section["html"].to_tuple() ('html', 'str', 'HTML output after conversion.') """ signature = get_signature(obj) name = "Parameters" - section = signature[name] + section: Section = signature[name] if name in doc: section = section.merge(doc[name], force=True) if section: doc.set_section(section, replace=True) name = "Attributes" - section = signature[name] + section: Section = signature[name] if name not in doc and not section: return doc[name].update(section) @@ -240,38 +217,25 @@ def parse_source(doc: Docstring, obj: Any): doc[name].set_item(item) -def postprocess(doc: Docstring, obj: object): - parse_bases(doc, obj) - parse_source(doc, obj) - if not callable(obj): +def postprocess_parameters(doc: Docstring, signature: Signature) -> None: # noqa: D103 + if "Parameters" not in doc: return + for item in doc["Parameters"].items: + description = item.description + if "{default}" in description.name and item.name in signature: + default = signature.defaults[item.name] + description.markdown = description.name.replace("{default}", default) - signature = get_signature(obj) - if signature.signature is None: - return - - if "Parameters" in doc: - for item in doc["Parameters"].items: - description = item.description - if "{default}" in description.name and item.name in signature: - default = signature.defaults[item.name] - description.markdown = description.name.replace("{default}", default) +def postprocess_returns(doc: Docstring, signature: Signature) -> None: # noqa: D103 for name in ["Returns", "Yields"]: if name in doc: section = doc[name] if not section.type: section.type = Type(getattr(signature, name.lower())) - if "Returns" not in doc and "Yields" not in doc: - from mkapi.core.node import get_kind - - kind = get_kind(obj) - if kind == "generator": - doc.type = Type(signature.yields) - else: - doc.type = Type(signature.returns) +def postprocess_sections(doc: Docstring, obj: object) -> None: # noqa: D103 sections: list[Section] = [] for section in doc.sections: if section.name not in ["Example", "Examples"]: @@ -288,13 +252,40 @@ def postprocess(doc: Docstring, obj: object): doc.sections = sections +def set_docstring_type(doc: Docstring, signature: Signature, obj: object) -> None: # noqa: D103 + from mkapi.core.node import get_kind + + if "Returns" not in doc and "Yields" not in doc: + if get_kind(obj) == "generator": + doc.type = Type(signature.yields) + else: + doc.type = Type(signature.returns) + + +def postprocess_docstring(doc: Docstring, obj: object) -> None: + """Docstring prostprocess.""" + parse_bases(doc, obj) + parse_source(doc, obj) + + if not callable(obj): + return + + signature = get_signature(obj) + if not signature.signature: + return + + postprocess_parameters(doc, signature) + postprocess_returns(doc, signature) + postprocess_sections(doc, obj) + set_docstring_type(doc, signature, obj) + + def get_docstring(obj: object) -> Docstring: - """Returns a [Docstring]() instance.""" + """Return a [Docstring]() instance.""" doc = inspect.getdoc(obj) - if doc: - sections = [get_section(*section) for section in split_section(doc)] - docstring = Docstring(sections) - else: + if not doc: return Docstring() - postprocess(docstring, obj) + sections = [get_section(*section_args) for section_args in split_section(doc)] + docstring = Docstring(sections) + postprocess_docstring(docstring, obj) return docstring diff --git a/tests/core/test_core_object.py b/tests/core/test_core_object.py index d6572cb7..9acba62b 100644 --- a/tests/core/test_core_object.py +++ b/tests/core/test_core_object.py @@ -20,9 +20,8 @@ def test_get_origin(): def test_get_sourcefile_and_lineno(): - sourcefile, lineno = get_sourcefile_and_lineno(Node) + sourcefile, _ = get_sourcefile_and_lineno(Node) assert sourcefile.endswith("node.py") - assert lineno == 15 def test_split_prefix_and_name(): From e6d3701b79512a593f2c8401373036fcef5126d5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 21:51:56 +0900 Subject: [PATCH 024/148] inherit.py --- src/mkapi/core/inherit.py | 56 ++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/src/mkapi/core/inherit.py b/src/mkapi/core/inherit.py index 5354c22f..a78ae45c 100644 --- a/src/mkapi/core/inherit.py +++ b/src/mkapi/core/inherit.py @@ -1,4 +1,4 @@ -"""This module implements the functionality of docstring inheritance.""" +"""Functionality of docstring inheritance.""" from collections.abc import Iterator from mkapi.core.base import Section @@ -7,7 +7,7 @@ def get_section(node: Node, name: str, mode: str) -> Section: - """Returns a tuple of (docstring section, signature section). + """Return a tuple of (docstring section, signature section). Args: node: Node instance. @@ -15,27 +15,24 @@ def get_section(node: Node, name: str, mode: str) -> Section: mode: Mode name: `Docstring` or `Signature`. Examples: - >>> node = get_node('mkapi.core.base.Type') - >>> section = get_section(node, 'Parameters', 'Docstring') - >>> 'name' in section + >>> node = get_node("mkapi.core.base.Type") + >>> section = get_section(node, "Parameters", "Docstring") + >>> "name" in section True - >>> section['name'].to_tuple() + >>> section["name"].to_tuple() ('name', 'str, optional', '') """ if mode == "Docstring": if name in node.docstring: return node.docstring[name] - else: - return Section(name) - else: - if hasattr(node.object.signature, name.lower()): - return node.object.signature[name] - else: - return Section(name) + return Section(name) + if hasattr(node.object.signature, name.lower()): + return node.object.signature[name] + return Section(name) def is_complete(node: Node, name: str = "both") -> bool: - """Returns True if docstring is complete. + """Return True if docstring is complete. Args: node: Node instance. @@ -44,10 +41,10 @@ def is_complete(node: Node, name: str = "both") -> bool: Examples: >>> from mkapi.core.object import get_object - >>> node = Node(get_object('mkapi.core.base.Base')) - >>> is_complete(node, 'Parameters') + >>> node = Node(get_object("mkapi.core.base.Base")) + >>> is_complete(node, "Parameters") True - >>> node = Node(get_object('mkapi.core.base.Type')) + >>> node = Node(get_object("mkapi.core.base.Type")) >>> is_complete(node) False """ @@ -61,14 +58,11 @@ def is_complete(node: Node, name: str = "both") -> bool: return False if not doc_section: return True - for item in doc_section.items: - if not item.description.name: - return False - return True + return all(item.description.name for item in doc_section.items) -def inherit_base(node: Node, base: Node, name: str = "both"): - """Inherits Parameters or Attributes section from base class. +def inherit_base(node: Node, base: Node, name: str = "both") -> None: + """Inherit Parameters or Attributes section from base class. Args: node: Node instance. @@ -78,12 +72,12 @@ def inherit_base(node: Node, base: Node, name: str = "both"): Examples: >>> from mkapi.core.object import get_object - >>> base = Node(get_object('mkapi.core.base.Base')) - >>> node = Node(get_object('mkapi.core.base.Type')) - >>> node.docstring['Parameters']['name'].to_tuple() + >>> base = Node(get_object("mkapi.core.base.Base")) + >>> node = Node(get_object("mkapi.core.base.Type")) + >>> node.docstring["Parameters"]["name"].to_tuple() ('name', 'str, optional', '') >>> inherit_base(node, base) - >>> node.docstring['Parameters']['name'].to_tuple() + >>> node.docstring["Parameters"]["name"].to_tuple() ('name', 'str, optional', 'Name of self.') """ if name == "both": @@ -96,11 +90,7 @@ def inherit_base(node: Node, base: Node, name: str = "both"): section = base_section.merge(node_section, force=True) if name == "Parameters": sig_section = get_section(node, name, "Signature") - items = [] - for item in section.items: - if item.name in sig_section: - items.append(item) - + items = [item for item in section.items if item.name in sig_section] section.items = items if section: node.docstring.set_section(section, replace=True) @@ -138,7 +128,7 @@ def gen(name: str = name) -> Iterator[Node]: def inherit(node: Node) -> None: - """Inherits Parameters and Attributes from superclasses. + """Inherit Parameters and Attributes from superclasses. Args: node: Node instance. From 0bcff3972ac5f25ae3d5cf5143a0b49b8e7fb160 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 21:55:50 +0900 Subject: [PATCH 025/148] page.py --- src/mkapi/core/page.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/mkapi/core/page.py b/src/mkapi/core/page.py index 3e1d5ee5..ed38ecbe 100644 --- a/src/mkapi/core/page.py +++ b/src/mkapi/core/page.py @@ -1,4 +1,4 @@ -"""Provide a Page class that works with other converter.""" +"""Page class that works with other converter.""" import re from collections.abc import Iterator from dataclasses import InitVar, dataclass, field @@ -12,10 +12,8 @@ from mkapi.core.node import Node, get_node MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) -NODE_PATTERN = re.compile( - r"(.*?)", - re.MULTILINE | re.DOTALL, -) +pattern = r"(.*?)" +NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) @dataclass @@ -23,13 +21,11 @@ class Page: """Page class works with [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). Args: - ---- source (str): Markdown source. abs_src_path: Absolute source path of Markdown. abs_api_paths: A list of API paths. Attributes: - ---------- markdown: Converted Markdown including API documentation. nodes: A list of Node instances. """ @@ -49,18 +45,17 @@ class Page: def __post_init__(self, source: str) -> None: self.markdown = "\n\n".join(self.split(source)) - def resolve_link(self, markdown: str) -> str: + def resolve_link(self, markdown: str) -> str: # noqa: D102 return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) - def resolve_link_from_base(self, base: Base) -> str: + def resolve_link_from_base(self, base: Base) -> str: # noqa: D102 if isinstance(base, Section) and base.name in ["Example", "Examples"]: return base.markdown return resolve_link(base.markdown, self.abs_src_path, self.abs_api_paths) - def split(self, source: str) -> Iterator[str]: - cursor = 0 + def split(self, source: str) -> Iterator[str]: # noqa: D102 callback = self.resolve_link_from_base - index = 0 + cursor = index = 0 for match in MKAPI_PATTERN.finditer(source): start, end = match.start(), match.end() if cursor < start: @@ -98,7 +93,6 @@ def content(self, html: str) -> str: """Return updated HTML to [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). Args: - ---- html: Input HTML converted by MkDocs. """ From a8762c4000016745520b25701bffd39b32e9515c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 22:50:39 +0900 Subject: [PATCH 026/148] postprocess.py --- src/mkapi/core/base.py | 3 +- src/mkapi/core/postprocess.py | 138 ++++++++++++++++------------------ 2 files changed, 65 insertions(+), 76 deletions(-) diff --git a/src/mkapi/core/base.py b/src/mkapi/core/base.py index 7daa33d9..60f6da04 100644 --- a/src/mkapi/core/base.py +++ b/src/mkapi/core/base.py @@ -28,7 +28,8 @@ class Base: name: str = "" #: Name of self. markdown: str = "" #: Markdown source. html: str = field(default="", init=False) #: HTML output after conversion. - callback: Callable[["Base"], str] | None = field(default=None, init=False) + # callback: Callable[["Base"], str] | None = field(default=None, init=False) + callback: Callable[..., str] | None = field(default=None, init=False) """Callback function to modify HTML output.""" def __repr__(self) -> str: diff --git a/src/mkapi/core/postprocess.py b/src/mkapi/core/postprocess.py index bab33139..ed2e3fa7 100644 --- a/src/mkapi/core/postprocess.py +++ b/src/mkapi/core/postprocess.py @@ -1,73 +1,78 @@ -from typing import Any, Dict, List, Optional - +"""Postprocess.""" from mkapi.core.base import Inline, Item, Section, Type from mkapi.core.node import Node from mkapi.core.renderer import renderer from mkapi.core.structure import Object -def sourcelink(object: Object) -> str: - if "property" in object.kind: - link = f'' - else: - link = "" - link += f'</>' +def sourcelink(obj: Object) -> str: # noqa: D103 + link = f'' if "property" in obj.kind else "" + link += f'</>' return link -def source_link_from_section_item(item: Item, object: Object): - link = sourcelink(object) +def source_link_from_section_item(item: Item, obj: Object) -> None: # noqa: D103 + link = sourcelink(obj) - def callback(inline, link=link): + def callback(inline: Inline, link: str = link) -> str: return inline.html + link item.description.callback = callback -def transform_property(node: Node, filters: List[str] = None): - section = None - members = [] +def transform_property(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 + section, members = None, [] for member in node.members: - object = member.object - if "property" in object.kind: + obj = member.object + if "property" in obj.kind: if section is None: section = node.docstring["Attributes"] - name = object.name - kind = object.kind - type = object.type description = member.docstring.sections[0].markdown - item = Item(name, type, Inline(description), kind=kind) + item = Item(obj.name, obj.type, Inline(description), kind=obj.kind) if filters and "sourcelink" in filters: - source_link_from_section_item(item, object) + source_link_from_section_item(item, obj) section.items.append(item) else: members.append(member) node.members = members -def get_type(node: Node) -> Type: - type = node.object.type - if type: - name = type.name - else: - for name in ["Returns", "Yields"]: - if name in node.docstring: - section = node.docstring[name] - if section.type: - name = section.type.name - break - else: - name = "" - return Type(name) +def get_type(node: Node) -> Type: # noqa: D103 + if type_ := node.object.type: + return Type(type_.name) + for name in ["Returns", "Yields"]: + if name in node.docstring and (section := node.docstring[name]).type: + return Type(section.type.name) + return Type() + + +def get_description(member: Node) -> str: # noqa: D103 + if member.docstring and "" in member.docstring: + description = member.docstring[""].markdown + return description.split("\n\n")[0] + return "" + + +def get_url(node: Node, obj: Object, filters: list[str]) -> str: # noqa: D103 + if "link" in filters or "all" in filters: + return "#" + obj.id + if filters and "apilink" in filters: + return "../" + node.object.id + "#" + obj.id + return "" -def transform_members(node: Node, mode: str, filters: Optional[List[str]] = None): - def is_member(kind): +def get_arguments(obj: Object) -> list[str] | None: # noqa: D103 + if obj.kind not in ["class", "dataclass"]: + return [item.name for item in obj.signature.parameters.items] + return None + + +def transform_members(node: Node, mode: str, filters: list[str] | None = None) -> None: # noqa: D103 + def is_member(kind: str) -> bool: if mode in ["method", "function"]: return mode in kind or kind == "generator" - else: - return mode in kind and "method" not in kind + return mode in kind and "method" not in kind members = [member for member in node.members if is_member(member.object.kind)] if not members: @@ -76,63 +81,46 @@ def is_member(kind): name = mode[0].upper() + mode[1:] + ("es" if mode == "class" else "s") section = Section(name) for member in members: - object = member.object - kind = object.kind - type = get_type(member) - if member.docstring and "" in member.docstring: - description = member.docstring[""].markdown - if "\n\n" in description: - description = description.split("\n\n")[0] - else: - description = "" - item = Item(object.name, type, Inline(description), kind) - item.markdown, url = "", "" - if filters and ("link" in filters or "all" in filters): - url = "#" + object.id - elif filters and "apilink" in filters: - url = "../" + node.object.id + "#" + object.id - signature: Dict[str, Any] = {} - if object.kind not in ["class", "dataclass"]: - args = [item.name for item in object.signature.parameters.items] - signature["arguments"] = args - else: - signature["arguments"] = None - item.html = renderer.render_object_member(object.name, url, signature) + obj = member.object + description = get_description(member) + item = Item(obj.name, get_type(member), Inline(description), obj.kind) + url = get_url(node, obj, filters) if filters else "" + signature = {"arguments": get_arguments(obj)} + item.html = renderer.render_object_member(obj.name, url, signature) + item.markdown = "" if filters and "sourcelink" in filters: - source_link_from_section_item(item, object) + source_link_from_section_item(item, obj) section.items.append(item) node.docstring.set_section(section) -def transform_class(node: Node, filters: Optional[List[str]] = None): +def transform_class(node: Node, filters: list[str] | None = None) -> None: # noqa:D103 if filters is None: filters = [] transform_property(node, filters) - transform_members(node, "class", ["link"] + filters) - transform_members(node, "method", ["link"] + filters) + transform_members(node, "class", ["link", *filters]) + transform_members(node, "method", ["link", *filters]) -def transform_module(node: Node, filters: Optional[List[str]] = None): +def transform_module(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 transform_members(node, "class", filters) transform_members(node, "function", filters) if not filters or "all" not in filters: node.members = [] -def sort(node: Node): - doc = node.docstring - for section in doc.sections: - if section.name in ["Classes", "Parameters"]: - continue - section.items = sorted(section.items, key=lambda x: x.name) +def sort_sections(node: Node) -> None: # noqa: D103 + for section in node.docstring.sections: + if section.name not in ["Classes", "Parameters"]: + section.items = sorted(section.items, key=lambda x: x.name) -def transform(node: Node, filters: Optional[List[str]] = None): +def transform(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 if node.object.kind.replace("abstract ", "") in ["class", "dataclass"]: transform_class(node, filters) elif node.object.kind in ["module", "package"]: transform_module(node, filters) for x in node.walk(): - sort(x) # type:ignore + sort_sections(x) for member in node.members: transform(member, filters) From 8c307691d2ca12fc8d8ca758c9fc554b97bd3590 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 28 Dec 2023 23:28:34 +0900 Subject: [PATCH 027/148] typing.py --- src/mkapi/inspect/typing.py | 44 +++++++++++++++++++++++----- tests/inspect/test_inspect_typing.py | 2 +- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/mkapi/inspect/typing.py b/src/mkapi/inspect/typing.py index fc85abb4..d5985744 100644 --- a/src/mkapi/inspect/typing.py +++ b/src/mkapi/inspect/typing.py @@ -50,6 +50,8 @@ def type_string_init_var(tp: InitVar, obj: object) -> str: # noqa: D103 return type_string(tp.type, obj=obj) +TYPE_STRING_IS = {inspect.Parameter.empty: "", tuple: "()"} + TYPE_STRING_FUNCTIONS = { NoneType: type_string_none, EllipsisType: type_string_ellipsis, @@ -87,6 +89,12 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq '...' >>> type_string([int, str]) '[int, str]' + >>> type_string(tuple) + '()' + >>> type_string(tuple[int, str]) + '(int, str)' + >>> type_string(tuple[int, ...]) + '(int, ...)' >>> type_string(int | str) 'int | str' >>> type_string("Node", obj=Node) @@ -103,8 +111,9 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq """ if kind == "yields": return type_string_yields(tp, obj) - if tp == inspect.Parameter.empty: - return "" + for obj_, str_ in TYPE_STRING_IS.items(): + if tp is obj_: + return str_ for type_, func in TYPE_STRING_FUNCTIONS.items(): if isinstance(tp, type_): return func(tp, obj) @@ -113,6 +122,26 @@ def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noq return get_link(tp) +def origin_args_string_union(args: tuple, args_list: list[str]) -> str: # noqa: D103 + if len(args) == 2 and args[1] == type(None): # noqa: PLR2004 + return f"{args_list[0]} | None" + return " | ".join(args_list) + + +def origin_args_string_tuple(args: tuple, args_list: list[str]) -> str: # noqa: D103 + if not args: + return "()" + if len(args) == 1: + return f"({args_list[0]},)" + return "(" + ", ".join(args_list) + ")" + + +ORIGIN_FUNCTIONS = [ + (Union, origin_args_string_union), + (tuple, origin_args_string_tuple), +] + + def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 """Return string expression for X[Y, Z, ...]. @@ -123,9 +152,11 @@ def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 Examples: >>> type_string_origin_args(list[str]) 'list[str]' + >>> type_string_origin_args(tuple[str, int]) + '(str, int)' >>> from typing import List, Tuple >>> type_string_origin_args(List[Tuple[int, str]]) - 'list[tuple[int, str]]' + 'list[(int, str)]' >>> from mkapi.core.node import Node >>> type_string_origin_args(list[Node]) 'list[[Node](!mkapi.core.node.Node)]' @@ -142,10 +173,9 @@ def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 """ origin, args = get_origin(tp), get_args(tp) args_list = [type_string(arg, obj=obj) for arg in args] - if origin is Union: - if len(args) == 2 and args[1] == type(None): # noqa: PLR2004 - return f"{args_list[0]} | None" - return " | ".join(args_list) + for origin_, func in ORIGIN_FUNCTIONS: + if origin is origin_: + return func(args, args_list) origin_str = type_string(origin, obj=obj) args_str = ", ".join(args_list) return f"{origin_str}[{args_str}]" diff --git a/tests/inspect/test_inspect_typing.py b/tests/inspect/test_inspect_typing.py index 902c4e49..6ff77326 100644 --- a/tests/inspect/test_inspect_typing.py +++ b/tests/inspect/test_inspect_typing.py @@ -7,7 +7,7 @@ def test_type_string(): assert type_string(list) == "list" - assert type_string(tuple) == "tuple" + assert type_string(tuple) == "()" assert type_string(dict) == "dict" assert type_string(Callable) == "[Callable](!collections.abc.Callable)" assert type_string(Signature) == "[Signature](!mkapi.inspect.signature.Signature)" From b0947ec820cec13fda4864d4a39b8350bc9811bd Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 29 Dec 2023 08:27:55 +0900 Subject: [PATCH 028/148] plugin --- mkdocs.yml | 3 +- pyproject.toml | 2 - src/mkapi/core/renderer.py | 4 +- src/mkapi/plugins/api.py | 103 -------------- src/mkapi/plugins/mkdocs.py | 196 ++++++++++++++++++++------- tests/plugins/test_plugins_mkdocs.py | 34 ++--- 6 files changed, 168 insertions(+), 174 deletions(-) delete mode 100644 src/mkapi/plugins/api.py diff --git a/mkdocs.yml b/mkdocs.yml index 2e020081..6ddd71e1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,4 +48,5 @@ markdown_extensions: - pymdownx.arithmatex extra_javascript: - - https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-MML-AM_CHTML + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js + # - https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-MML-AM_CHTML \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c11c5cf1..bd2f99b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,10 +54,8 @@ addopts = [ "--doctest-modules", "--cov=mkapi", "--cov-report=lcov:lcov.info", - # "--cov-report=term:skip-covered", ] doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] -# testpaths = ["tests", "src/mkapi"] [tool.coverage.run] omit = ["src/mkapi/__about__.py"] diff --git a/src/mkapi/core/renderer.py b/src/mkapi/core/renderer.py index 86c29d64..42f3070c 100644 --- a/src/mkapi/core/renderer.py +++ b/src/mkapi/core/renderer.py @@ -1,6 +1,4 @@ -"""This module provides Renderer class that renders Node instance -to create API documentation. -""" +"""Renderer class that renders Node instance to create API documentation.""" import os from dataclasses import dataclass, field from typing import Any, Dict, List diff --git a/src/mkapi/plugins/api.py b/src/mkapi/plugins/api.py deleted file mode 100644 index 6abe1a66..00000000 --- a/src/mkapi/plugins/api.py +++ /dev/null @@ -1,103 +0,0 @@ -import atexit -import logging -import os -import shutil -import sys -from typing import Dict, List, Optional, Tuple - -from mkapi.core import preprocess -from mkapi.core.module import Module, get_module - -logger = logging.getLogger("mkdocs") - - -def create_nav(config, global_filters): - nav = config["nav"] - docs_dir = config["docs_dir"] - config_dir = os.path.dirname(config["config_file_path"]) - abs_api_paths = [] - for page in nav: - if isinstance(page, dict): - for key, value in page.items(): - if isinstance(value, str) and value.startswith("mkapi/"): - page[key], abs_api_paths_ = collect( - value, - docs_dir, - config_dir, - global_filters, - ) - abs_api_paths.extend(abs_api_paths_) - return config, abs_api_paths - - -def collect(path: str, docs_dir: str, config_dir, global_filters) -> tuple[list, list]: - _, api_path, *paths, package_path = path.split("/") - abs_api_path = os.path.join(docs_dir, api_path) - if os.path.exists(abs_api_path): - logger.error(f"[MkAPI] {abs_api_path} exists: Delete manually for safety.") - sys.exit(1) - os.mkdir(abs_api_path) - os.mkdir(os.path.join(abs_api_path, "source")) - atexit.register(lambda path=abs_api_path: rmtree(path)) - - root = os.path.join(config_dir, *paths) - if root not in sys.path: - sys.path.insert(0, root) - - package_path, filters = preprocess.split_filters(package_path) - filters = preprocess.update_filters(global_filters, filters) - - nav = [] - abs_api_paths = [] - modules: dict[str, str] = {} - package = None - - def add_page(module: Module, package: Optional[str]): - page_file = module.object.id + ".md" - abs_path = os.path.join(abs_api_path, page_file) - abs_api_paths.append(abs_path) - create_page(abs_path, module, filters) - page_name = module.object.id - if package and "short_nav" in filters and page_name != package: - page_name = page_name[len(package) + 1 :] - modules[page_name] = os.path.join(api_path, page_file) - - abs_path = os.path.join(abs_api_path, "source", page_file) - create_source_page(abs_path, module, filters) - - module = get_module(package_path) - for m in module: - print(m) - if m.object.kind == "package": - if package and modules: - nav.append({package: modules}) - package = m.object.id - modules = {} - if m.docstring or any(s.docstring for s in m.members): - add_page(m, package) - else: - add_page(m, package) - if package and modules: - nav.append({package: modules}) - - return nav, abs_api_paths - - -def create_page(path: str, module: Module, filters: list[str]): - with open(path, "w") as f: - f.write(module.get_markdown(filters)) - - -def create_source_page(path: str, module: Module, filters: list[str]): - filters_str = "|".join(filters) - with open(path, "w") as f: - f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") - - -def rmtree(path: str): - if not os.path.exists(path): - return - try: - shutil.rmtree(path) - except PermissionError: - logger.warning(f"[MkAPI] Couldn't delete directory: {path}") diff --git a/src/mkapi/plugins/mkdocs.py b/src/mkapi/plugins/mkdocs.py index 5428f5cb..086dda21 100644 --- a/src/mkapi/plugins/mkdocs.py +++ b/src/mkapi/plugins/mkdocs.py @@ -1,20 +1,26 @@ -"""This module provides the MkapiPlugin class. +"""MkAPI Plugin class. -MkapiPlugin is a MkDocs plugin that creates Python API documentation from Docstring. +MkAPI Plugin is a MkDocs plugin that creates Python API documentation +from Docstring. """ +import atexit import inspect import logging import os import re +import shutil import sys +from pathlib import Path import yaml from mkdocs.config import config_options +from mkdocs.config.base import Config from mkdocs.plugins import BasePlugin from mkdocs.structure.files import get_files import mkapi -import mkapi.plugins.api +from mkapi.core.filter import split_filters, update_filters +from mkapi.core.module import Module, get_module from mkapi.core.object import get_object from mkapi.core.page import Page @@ -22,62 +28,59 @@ global_config = {} -class MkapiPlugin(BasePlugin): +class MkapiConfig(Config): + """Specify the config schema.""" + + src_dirs = config_options.Type(list[str], default=[]) + on_config = config_options.Type(str, default="") + filters = config_options.Type(list[str], default=[]) + callback = config_options.Type(str, default="") + + +class MkapiPlugin(BasePlugin[MkapiConfig]): """MkapiPlugin class for API generation.""" - config_scheme = ( - ("src_dirs", config_options.Type(list, default=[])), - ("on_config", config_options.Type(str, default="")), - ("filters", config_options.Type(list, default=[])), - ("callback", config_options.Type(str, default="")), - ) server = None - def on_config(self, config, **kwargs): - """Inserts `src_dirs` to `sys.path`.""" - config_dir = os.path.dirname(config["config_file_path"]) - for src_dir in self.config["src_dirs"]: - path = os.path.normpath(os.path.join(config_dir, src_dir)) - if path not in sys.path: + def on_config(self, config: MkapiConfig, **kwargs) -> MkapiConfig: + """Insert `src_dirs` to `sys.path`.""" + config_dir = Path(config.config_file_path).parent + for src_dir in self.config.src_dirs: + if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: sys.path.insert(0, path) - if not self.config["src_dirs"]: - path = os.getcwd() - if path not in sys.path: - sys.path.insert(0, path) - self.pages = {} - self.abs_api_paths = [] + if not self.config.src_dirs and (path := Path.cwd()) not in sys.path: + sys.path.insert(0, str(path)) + self.pages, self.abs_api_paths = {}, [] if not self.server: - config, self.abs_api_paths = mkapi.plugins.api.create_nav( - config, self.config["filters"] - ) + config, self.abs_api_paths = create_nav(config, self.config.filters) global_config["config"] = config global_config["abs_api_paths"] = self.abs_api_paths else: config = global_config["config"] self.abs_api_paths = global_config["abs_api_paths"] - if self.config["on_config"]: - on_config = get_object(self.config["on_config"]) + if self.config.on_config: + on_config = get_object(self.config.on_config) kwargs = {} params = inspect.signature(on_config).parameters if "config" in params: kwargs["config"] = config if "mkapi" in params: kwargs["mkapi"] = self - logger.info(f"[MkAPI] Calling user 'on_config' with {list(kwargs)}") + msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" + logger.info(msg) config_ = on_config(**kwargs) if config_ is not None: config = config_ - ext = config["markdown_extensions"] - if "admonition" not in ext: + if "admonition" not in config["markdown_extensions"]: config["markdown_extensions"].append("admonition") return config - def on_files(self, files, config, **kwargs): - """Collects plugin CSS ans JavaScript and appends them to `files`.""" - root = os.path.join(os.path.dirname(mkapi.__file__), "theme") + def on_files(self, files, config: MkapiConfig, **kwargs): + """Collect plugin CSS/JavaScript and appends them to `files`.""" + root = Path(mkapi.__file__).parent / "theme" docs_dir = config["docs_dir"] config["docs_dir"] = root files_ = get_files(config) @@ -96,8 +99,7 @@ def on_files(self, files, config, **kwargs): files.append(file) js.append(path) elif path.endswith(".yml"): - path = os.path.normpath(os.path.join(root, path)) - with open(path) as f: + with (root / path).open() as f: data = yaml.safe_load(f) css = data.get("extra_css", []) + css js = data.get("extra_javascript", []) + js @@ -109,17 +111,20 @@ def on_files(self, files, config, **kwargs): return files def on_page_markdown(self, markdown, page, config, files, **kwargs): - """Converts Markdown source to intermidiate version.""" + """Convert Markdown source to intermidiate version.""" abs_src_path = page.file.abs_src_path clean_page_title(page) page = Page( - markdown, abs_src_path, self.abs_api_paths, filters=self.config["filters"] + markdown, + abs_src_path, + self.abs_api_paths, + filters=self.config["filters"], ) self.pages[abs_src_path] = page return page.markdown def on_page_content(self, html, page, config, files, **kwargs): - """Merges html and MkAPI's node structure.""" + """Merge HTML and MkAPI's node structure.""" if page.title: page.title = re.sub(r"<.*?>", "", page.title) abs_src_path = page.file.abs_src_path @@ -131,27 +136,126 @@ def on_page_context(self, context, page, config, nav, **kwargs): if abs_src_path in self.abs_api_paths: clear_prefix(page.toc, 2) else: - for level, id in self.pages[abs_src_path].headings: - clear_prefix(page.toc, level, id) + for level, id_ in self.pages[abs_src_path].headings: + clear_prefix(page.toc, level, id_) return context def on_serve(self, server, config, builder, **kwargs): for path in ["theme", "templates"]: - root = os.path.join(os.path.dirname(mkapi.__file__), path) - server.watch(root, builder) + server.watch(Path(mkapi.__file__) / path, builder) self.__class__.server = server return server -def clear_prefix(toc, level: int, id: str = ""): +def create_nav( + config: MkapiConfig, + filters: list[str], +) -> tuple[MkapiConfig, list[str]]: + """Create nav.""" + nav = config["nav"] + docs_dir = config["docs_dir"] + config_dir = Path(config.config_file_path).parent + abs_api_paths: list[str] = [] + for page in nav: + if isinstance(page, dict): + for key, value in page.items(): + if isinstance(value, str) and value.startswith("mkapi/"): + _ = collect(value, docs_dir, config_dir, filters) + page[key], abs_api_paths_ = _ + abs_api_paths.extend(abs_api_paths_) + return config, abs_api_paths + + +def collect( + path: str, + docs_dir: str, + config_dir: Path, + global_filters: list[str], +) -> tuple[list, list]: + """Collect pages.""" + _, api_path, *paths, package_path = path.split("/") + abs_api_path = Path(docs_dir) / api_path + if abs_api_path.exists(): + msg = f"[MkAPI] {abs_api_path} exists: Delete manually for safety." + logger.error(msg) + sys.exit(1) + Path.mkdir(abs_api_path / "source", parents=True) + atexit.register(lambda path=abs_api_path: rmtree(path)) + + if (root := config_dir.joinpath(*paths)) not in sys.path: + sys.path.insert(0, str(root)) + + package_path, filters = split_filters(package_path) + filters = update_filters(global_filters, filters) + + nav = [] + abs_api_paths: list[Path] = [] + modules: dict[str, str] = {} + package = None + + def add_page(module: Module, package: str | None) -> None: + page_file = module.object.id + ".md" + abs_path = abs_api_path / page_file + abs_api_paths.append(abs_path) + create_page(abs_path, module, filters) + page_name = module.object.id + if package and "short_nav" in filters and page_name != package: + page_name = page_name[len(package) + 1 :] + modules[page_name] = str(Path(api_path) / page_file) + abs_path = abs_api_path / "source" / page_file + create_source_page(abs_path, module, filters) + + module = get_module(package_path) + for m in module: + if m.object.kind == "package": + if package and modules: + nav.append({package: modules}) + package = m.object.id + modules.clear() + if m.docstring or any(s.docstring for s in m.members): + add_page(m, package) + else: + add_page(m, package) + if package and modules: + nav.append({package: modules}) + + return nav, abs_api_paths + + +def create_page(path: Path, module: Module, filters: list[str]) -> None: + """Create a page.""" + with path.open("w") as f: + f.write(module.get_markdown(filters)) + + +def create_source_page(path: Path, module: Module, filters: list[str]) -> None: + """Create a page for source.""" + filters_str = "|".join(filters) + with path.open("w") as f: + f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") + + +def clear_prefix(toc, level: int, id_: str = "") -> None: + """Clear prefix.""" for toc_item in toc: - if toc_item.level >= level and (not id or toc_item.title == id): + if toc_item.level >= level and (not id_ or toc_item.title == id_): toc_item.title = toc_item.title.split(".")[-1] clear_prefix(toc_item.children, level) - return -def clean_page_title(page): +def clean_page_title(page) -> None: + """Clean page title.""" title = page.title if title.startswith("![mkapi]("): page.title = title[9:-1].split("|")[0] + + +def rmtree(path: Path) -> None: + """Delete directory created by MkAPI.""" + if not path.exists(): + return + try: + shutil.rmtree(path) + except PermissionError: + msg = f"[MkAPI] Couldn't delete directory: {path}" + logger.warning(msg) diff --git a/tests/plugins/test_plugins_mkdocs.py b/tests/plugins/test_plugins_mkdocs.py index 75591a4d..0ec3b739 100644 --- a/tests/plugins/test_plugins_mkdocs.py +++ b/tests/plugins/test_plugins_mkdocs.py @@ -1,6 +1,7 @@ import os import shutil import subprocess +from pathlib import Path import pytest from mkdocs.config import load_config @@ -12,48 +13,42 @@ @pytest.fixture(scope="module") def config_file(): - root = os.path.join(mkapi.__file__, "../..") - config_file = os.path.join(root, "mkdocs.yml") - config_file = os.path.normpath(config_file) - return config_file + root = Path(mkapi.__file__).parent.parent + config_file = root / "mkdocs.yml" + return os.path.normpath(config_file) @pytest.fixture(scope="module") def config(config_file): config = load_config(config_file) plugin = config["plugins"]["mkapi"] - config = plugin.on_config(config) - return config + return plugin.on_config(config) @pytest.fixture(scope="module") def plugin(config): - plugin = config["plugins"]["mkapi"] - return plugin + return config["plugins"]["mkapi"] @pytest.fixture(scope="module") def env(config): - env = config["theme"].get_env() - return env + return config["theme"].get_env() @pytest.fixture(scope="module") def files(config, plugin, env): files = get_files(config) files.add_files_from_theme(env, config) - files = plugin.on_files(files, config) - return files + return plugin.on_files(files, config) @pytest.fixture(scope="module") -def nav(config, plugin, files): - nav = get_navigation(files, config) - return nav +def nav(config, files): + return get_navigation(files, config) def test_plugins_mkdocs_config_file(config_file): - assert os.path.exists(config_file) + assert Path(config_file).exists() def test_plugins_mkdocs_config(config): @@ -61,9 +56,10 @@ def test_plugins_mkdocs_config(config): def test_plugins_mkdocs_build(): - def run(command): - assert subprocess.run(command.split()).returncode == 0 + def run(command: str): + args = command.split() + assert subprocess.run(args, check=False).returncode == 0 # noqa: S603 - if os.path.exists("docs/api"): + if Path("docs/api").exists(): shutil.rmtree("docs/api") run("mkdocs build") From 24d9e63826176fcb8cf6080edc27338a402f9604 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 29 Dec 2023 09:51:10 +0900 Subject: [PATCH 029/148] renderer.py --- src/mkapi/core/renderer.py | 85 +++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/mkapi/core/renderer.py b/src/mkapi/core/renderer.py index 42f3070c..f9a386c0 100644 --- a/src/mkapi/core/renderer.py +++ b/src/mkapi/core/renderer.py @@ -1,7 +1,8 @@ """Renderer class that renders Node instance to create API documentation.""" import os from dataclasses import dataclass, field -from typing import Any, Dict, List +from pathlib import Path +from typing import Any from jinja2 import Environment, FileSystemLoader, Template, select_autoescape @@ -16,8 +17,7 @@ @dataclass class Renderer: - """Renderer instance renders Node instance recursively to create - API documentation. + """Renderer instance renders Node instance recursively to create API documentation. Attributes: templates: Jinja template dictionary. @@ -25,62 +25,61 @@ class Renderer: templates: dict[str, Template] = field(default_factory=dict, init=False) - def __post_init__(self): - path = os.path.join(os.path.dirname(mkapi.__file__), "templates") + def __post_init__(self) -> None: + path = Path(mkapi.__file__).parent / "templates" loader = FileSystemLoader(path) env = Environment(loader=loader, autoescape=select_autoescape(["jinja2"])) for name in os.listdir(path): template = env.get_template(name) - name = os.path.splitext(name)[0] - self.templates[name] = template + self.templates[Path(name).stem] = template def render(self, node: Node, filters: list[str] | None = None) -> str: - """Returns a rendered HTML for Node. + """Return a rendered HTML for Node. Args: node: Node instance. + filters: Filters. """ - object = self.render_object(node.object, filters=filters) + obj = self.render_object(node.object, filters=filters) docstring = self.render_docstring(node.docstring, filters=filters) members = [self.render(member, filters) for member in node.members] - return self.render_node(node, object, docstring, members) + return self.render_node(node, obj, docstring, members) def render_node( self, node: Node, - object: str, + obj: str, docstring: str, members: list[str], ) -> str: - """Returns a rendered HTML for Node using prerendered components. + """Return a rendered HTML for Node using prerendered components. Args: node: Node instance. - object: Rendered HTML for Object instance. + obj: Rendered HTML for Object instance. docstring: Rendered HTML for Docstring instance. members: A list of rendered HTML for member Node instances. """ template = self.templates["node"] return template.render( node=node, - object=object, + object=obj, docstring=docstring, members=members, ) - def render_object(self, object: Object, filters: list[str] = None) -> str: - """Returns a rendered HTML for Object. + def render_object(self, obj: Object, filters: list[str] | None = None) -> str: + """Return a rendered HTML for Object. Args: - object: Object instance. + obj: Object instance. filters: Filters. """ - if filters is None: - filters = [] - context = link.resolve_object(object.html) + filters = filters if filters else [] + context = link.resolve_object(obj.html) level = context.get("level") if level: - if object.kind in ["module", "package"]: + if obj.kind in ["module", "package"]: filters.append("plain") elif "plain" in filters: del filters[filters.index("plain")] @@ -88,7 +87,7 @@ def render_object(self, object: Object, filters: list[str] = None) -> str: else: tag = "div" template = self.templates["object"] - return template.render(context, object=object, tag=tag, filters=filters) + return template.render(context, object=obj, tag=tag, filters=filters) def render_object_member( self, @@ -96,7 +95,7 @@ def render_object_member( url: str, signature: dict[str, Any], ) -> str: - """Returns a rendered HTML for Object in toc. + """Return a rendered HTML for Object in toc. Args: name: Object name. @@ -106,11 +105,16 @@ def render_object_member( template = self.templates["member"] return template.render(name=name, url=url, signature=signature) - def render_docstring(self, docstring: Docstring, filters: list[str] = None) -> str: - """Returns a rendered HTML for Docstring. + def render_docstring( + self, + docstring: Docstring, + filters: list[str] | None = None, + ) -> str: + """Return a rendered HTML for Docstring. Args: docstring: Docstring instance. + filters: Filters. """ if not docstring: return "" @@ -122,21 +126,20 @@ def render_docstring(self, docstring: Docstring, filters: list[str] = None) -> s section.html = self.render_section(section, filters) return template.render(docstring=docstring) - def render_section(self, section: Section, filters: list[str] = None) -> str: - """Returns a rendered HTML for Section. + def render_section(self, section: Section, filters: list[str] | None = None) -> str: + """Return a rendered HTML for Section. Args: section: Section instance. + filters: Filters. """ - if filters is None: - filters = [] + filters = filters if filters else [] if section.name == "Bases": return self.templates["bases"].render(section=section) - else: - return self.templates["items"].render(section=section, filters=filters) + return self.templates["items"].render(section=section, filters=filters) - def render_module(self, module: Module, filters: list[str] = None) -> str: - """Returns a rendered Markdown for Module. + def render_module(self, module: Module, filters: list[str] | None = None) -> str: + """Return a rendered Markdown for Module. Args: module: Module instance. @@ -148,8 +151,7 @@ def render_module(self, module: Module, filters: list[str] = None) -> str: will be converted into HTML by MkDocs. Then the HTML is rendered into HTML again by other functions in this module. """ - if filters is None: - filters = [] + filters = filters if filters else [] module_filter = "" if "upper" in filters: module_filter = "|upper" @@ -163,12 +165,17 @@ def render_module(self, module: Module, filters: list[str] = None) -> str: object_filter=object_filter, ) - def render_code(self, code: Code, filters: list[str] = None) -> str: - if filters is None: - filters = [] + def render_code(self, code: Code, filters: list[str] | None = None) -> str: + """Return a rendered Markdown for source code. + + Args: + code: Code instance. + filters: Filters. + """ + filters = filters if filters else [] template = self.templates["code"] return template.render(code=code, module=code.module, filters=filters) -#: Renderer instance that can be used globally. +#: Renderer instance that is used globally. renderer: Renderer = Renderer() From bf9a582d7df470bd33076dd99fe2d808de080da7 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 29 Dec 2023 20:11:17 +0900 Subject: [PATCH 030/148] before typechecking --- docs/usage/custom.md | 6 +- examples/docs/custom.css | 3 + examples/docs/example.md | 0 examples/docs/index.md | 1 + examples/mkdocs.yml | 29 ++ examples/typing/__init__.py | 0 examples/typing/current.py | 2 + examples/typing/future.py | 5 + mkdocs.yml | 4 +- pyproject.toml | 4 +- src/mkapi/core/page.py | 4 +- src/mkapi/plugins/mkdocs.py | 318 ++++++++++-------- ...test_core_abc.py => test_core_node_abc.py} | 9 +- tests/inspect/test_inspect_attribute.py | 9 +- tests/inspect/test_inspect_signature.py | 1 - .../test_inspect_signature_typecheck.py | 13 + tests/plugins/test_plugins_mkdocs.py | 96 +++--- 17 files changed, 310 insertions(+), 194 deletions(-) create mode 100644 examples/docs/custom.css create mode 100644 examples/docs/example.md create mode 100644 examples/docs/index.md create mode 100644 examples/mkdocs.yml create mode 100644 examples/typing/__init__.py create mode 100644 examples/typing/current.py create mode 100644 examples/typing/future.py rename tests/core/{test_core_abc.py => test_core_node_abc.py} (89%) create mode 100644 tests/inspect/test_inspect_signature_typecheck.py diff --git a/docs/usage/custom.md b/docs/usage/custom.md index 6c58476e..ae2cc89c 100644 --- a/docs/usage/custom.md +++ b/docs/usage/custom.md @@ -1,6 +1,6 @@ # Customization -## Customization 'on_config'. +## Customization 'on_config' MkAPI has an option `on_config` to allow users to configure MkDocs/MkAPI or user system environment. Here is an example directory structure and the corresponding `mkdocs.yml`: @@ -26,7 +26,7 @@ plugins: Customization script is defined in `examples/custom.py`: -#File examples/custom.py {%=/examples/custom.py%} +# File examples/custom.py {%=/examples/custom.py%} Let's build the documentation. @@ -72,7 +72,7 @@ $ mkdocs build INFO - [MkAPI] Calling user 'on_config' with ['config', 'mkapi'] Called with config and mkapi. C:\Users\daizu\Documents\github\mkapi\docs - + INFO - Cleaning site directory ... ~~~ diff --git a/examples/docs/custom.css b/examples/docs/custom.css new file mode 100644 index 00000000..543a6c4a --- /dev/null +++ b/examples/docs/custom.css @@ -0,0 +1,3 @@ +h1, h2, h3 { + margin-bottom: 10px; +} diff --git a/examples/docs/example.md b/examples/docs/example.md new file mode 100644 index 00000000..e69de29b diff --git a/examples/docs/index.md b/examples/docs/index.md new file mode 100644 index 00000000..2b2af8e8 --- /dev/null +++ b/examples/docs/index.md @@ -0,0 +1 @@ +# Demo diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml new file mode 100644 index 00000000..19d00a77 --- /dev/null +++ b/examples/mkdocs.yml @@ -0,0 +1,29 @@ +site_name: Doc for CI +site_url: https://daizutabi.github.io/mkapi/ +site_description: API documentation with MkDocs. +site_author: daizutabi +repo_url: https://github.com/daizutabi/mkapi/ +edit_uri: "" +theme: + name: mkdocs + highlightjs: true + hljs_languages: + - yaml +plugins: + - mkapi: + src_dirs: [.] + on_config: custom.on_config +nav: + - index.md + - Examples: + - example.md + - mkapi/api/mkapi.plugins + - ABC: mkapi/api/mkapi.inspect + - API: mkapi/api/mkapi.inspect|upper|strict + - mkapi/api/mkapi.plugins +extra_css: + - custom.css +extra_javascript: + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js +markdown_extensions: + - pymdownx.arithmatex \ No newline at end of file diff --git a/examples/typing/__init__.py b/examples/typing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/typing/current.py b/examples/typing/current.py new file mode 100644 index 00000000..8dba3eec --- /dev/null +++ b/examples/typing/current.py @@ -0,0 +1,2 @@ +def func(x: int) -> str: + return str(x) diff --git a/examples/typing/future.py b/examples/typing/future.py new file mode 100644 index 00000000..d34e22ea --- /dev/null +++ b/examples/typing/future.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def func(x: int) -> str: + return str(x) diff --git a/mkdocs.yml b/mkdocs.yml index 6ddd71e1..18883e0f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ repo_url: https://github.com/daizutabi/mkapi/ edit_uri: "" theme: - name: ivory + name: mkdocs highlightjs: true hljs_languages: - yaml @@ -18,8 +18,6 @@ extra_css: plugins: - search - - pheasant: - version: mkapi - mkapi: src_dirs: [examples] on_config: custom.on_config diff --git a/pyproject.toml b/pyproject.toml index bd2f99b0..25a2c787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ Issues = "https://github.com/daizutabi/mkapi/issues" mkapi = "mkapi.main:cli" [project.entry-points."mkdocs.plugins"] -mkapi = "mkapi.plugins.mkdocs:MkapiPlugin" +mkapi = "mkapi.plugins.mkdocs:MkAPIPlugin" [tool.hatch.version] path = "src/mkapi/__about__.py" @@ -43,7 +43,7 @@ packages = ["src/mkapi"] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] [tool.hatch.envs.default] -dependencies = ["pytest-cov"] +dependencies = ["pytest-cov", "pymdown-extensions"] [tool.hatch.envs.default.scripts] test = "pytest {args:tests src/mkapi}" diff --git a/src/mkapi/core/page.py b/src/mkapi/core/page.py index ed38ecbe..2467b7fb 100644 --- a/src/mkapi/core/page.py +++ b/src/mkapi/core/page.py @@ -18,7 +18,7 @@ @dataclass class Page: - """Page class works with [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). + """Page class works with [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). Args: source (str): Markdown source. @@ -90,7 +90,7 @@ def split(self, source: str) -> Iterator[str]: # noqa: D102 yield self.resolve_link(markdown) def content(self, html: str) -> str: - """Return updated HTML to [MkapiPlugin](mkapi.plugins.mkdocs.MkapiPlugin). + """Return modified HTML to [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). Args: html: Input HTML converted by MkDocs. diff --git a/src/mkapi/plugins/mkdocs.py b/src/mkapi/plugins/mkdocs.py index 086dda21..295b5c29 100644 --- a/src/mkapi/plugins/mkdocs.py +++ b/src/mkapi/plugins/mkdocs.py @@ -10,87 +10,69 @@ import re import shutil import sys +from collections.abc import Callable from pathlib import Path +from typing import TypeGuard import yaml from mkdocs.config import config_options from mkdocs.config.base import Config +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.livereload import LiveReloadServer from mkdocs.plugins import BasePlugin -from mkdocs.structure.files import get_files +from mkdocs.structure.files import Files, get_files +from mkdocs.structure.nav import Navigation +from mkdocs.structure.pages import Page as MkDocsPage +from mkdocs.structure.toc import AnchorLink, TableOfContents +from mkdocs.utils.templates import TemplateContext import mkapi from mkapi.core.filter import split_filters, update_filters from mkapi.core.module import Module, get_module from mkapi.core.object import get_object -from mkapi.core.page import Page +from mkapi.core.page import Page as MkAPIPage logger = logging.getLogger("mkdocs") global_config = {} -class MkapiConfig(Config): +class MkAPIConfig(Config): """Specify the config schema.""" - src_dirs = config_options.Type(list[str], default=[]) + src_dirs = config_options.Type(list, default=[]) on_config = config_options.Type(str, default="") - filters = config_options.Type(list[str], default=[]) + filters = config_options.Type(list, default=[]) callback = config_options.Type(str, default="") + abs_api_paths = config_options.Type(list, default=[]) + pages = config_options.Type(dict, default={}) -class MkapiPlugin(BasePlugin[MkapiConfig]): - """MkapiPlugin class for API generation.""" +class MkAPIPlugin(BasePlugin[MkAPIConfig]): + """MkAPIPlugin class for API generation.""" server = None - def on_config(self, config: MkapiConfig, **kwargs) -> MkapiConfig: + def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002 """Insert `src_dirs` to `sys.path`.""" - config_dir = Path(config.config_file_path).parent - for src_dir in self.config.src_dirs: - if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: - sys.path.insert(0, path) - if not self.config.src_dirs and (path := Path.cwd()) not in sys.path: - sys.path.insert(0, str(path)) - self.pages, self.abs_api_paths = {}, [] - if not self.server: - config, self.abs_api_paths = create_nav(config, self.config.filters) - global_config["config"] = config - global_config["abs_api_paths"] = self.abs_api_paths - else: - config = global_config["config"] - self.abs_api_paths = global_config["abs_api_paths"] - - if self.config.on_config: - on_config = get_object(self.config.on_config) - kwargs = {} - params = inspect.signature(on_config).parameters - if "config" in params: - kwargs["config"] = config - if "mkapi" in params: - kwargs["mkapi"] = self - msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" - logger.info(msg) - config_ = on_config(**kwargs) - if config_ is not None: - config = config_ - - if "admonition" not in config["markdown_extensions"]: - config["markdown_extensions"].append("admonition") - - return config - - def on_files(self, files, config: MkapiConfig, **kwargs): + insert_sys_path(self.config) + update_config(config, self) + if "admonition" not in config.markdown_extensions: + config.markdown_extensions.append("admonition") + return on_config_plugin(config, self) + + def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 """Collect plugin CSS/JavaScript and appends them to `files`.""" root = Path(mkapi.__file__).parent / "theme" - docs_dir = config["docs_dir"] - config["docs_dir"] = root - files_ = get_files(config) - config["docs_dir"] = docs_dir - theme_name = config["theme"].name + docs_dir = config.docs_dir + config.docs_dir = root.as_posix() + theme_files = get_files(config) + config.docs_dir = docs_dir + theme_name = config.theme.name or "mkdocs" css = [] js = [] - for file in files_: - path = file.src_path.replace("\\", "/") + for file in theme_files: + path = Path(file.src_path).as_posix() if path.endswith(".css"): if "common" in path or theme_name in path: files.append(file) @@ -103,125 +85,183 @@ def on_files(self, files, config: MkapiConfig, **kwargs): data = yaml.safe_load(f) css = data.get("extra_css", []) + css js = data.get("extra_javascript", []) + js - css = [x for x in css if x not in config["extra_css"]] - js = [x for x in js if x not in config["extra_javascript"]] - config["extra_css"] = css + config["extra_css"] - config["extra_javascript"] = js + config["extra_javascript"] + css = [x for x in css if x not in config.extra_css] + js = [x for x in js if x not in config.extra_javascript] + config.extra_css.extend(css) + config.extra_javascript.extend(js) return files - def on_page_markdown(self, markdown, page, config, files, **kwargs): + def on_page_markdown( + self, + markdown: str, + page: MkDocsPage, + config: MkDocsConfig, # noqa: ARG002 + files: Files, # noqa: ARG002 + **kwargs, # noqa: ARG002 + ) -> str: """Convert Markdown source to intermidiate version.""" abs_src_path = page.file.abs_src_path clean_page_title(page) - page = Page( - markdown, - abs_src_path, - self.abs_api_paths, - filters=self.config["filters"], - ) - self.pages[abs_src_path] = page - return page.markdown - - def on_page_content(self, html, page, config, files, **kwargs): + abs_api_paths = self.config.abs_api_paths + filters = self.config.filters + mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) + self.config.pages[abs_src_path] = mkapi_page + return mkapi_page.markdown + + def on_page_content( + self, + html: str, + page: MkDocsPage, + config: MkDocsConfig, # noqa: ARG002 + files: Files, # noqa: ARG002 + **kwargs, # noqa: ARG002 + ) -> str: """Merge HTML and MkAPI's node structure.""" if page.title: - page.title = re.sub(r"<.*?>", "", page.title) + page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 abs_src_path = page.file.abs_src_path - page = self.pages[abs_src_path] - return page.content(html) - - def on_page_context(self, context, page, config, nav, **kwargs): + mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + return mkapi_page.content(html) + + def on_page_context( + self, + context: TemplateContext, + page: MkDocsPage, + config: MkDocsConfig, # noqa: ARG002 + nav: Navigation, # noqa: ARG002 + **kwargs, # noqa: ARG002 + ) -> TemplateContext: + """Clear prefix in toc.""" abs_src_path = page.file.abs_src_path - if abs_src_path in self.abs_api_paths: + if abs_src_path in self.config.abs_api_paths: clear_prefix(page.toc, 2) else: - for level, id_ in self.pages[abs_src_path].headings: + mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + for level, id_ in mkapi_page.headings: clear_prefix(page.toc, level, id_) return context - def on_serve(self, server, config, builder, **kwargs): + def on_serve( # noqa: D102 + self, + server: LiveReloadServer, + config: MkDocsConfig, # noqa: ARG002 + builder: Callable, + **kwargs, # noqa: ARG002 + ) -> LiveReloadServer: for path in ["theme", "templates"]: - server.watch(Path(mkapi.__file__) / path, builder) + path_str = (Path(mkapi.__file__).parent / path).as_posix() + server.watch(path_str, builder) self.__class__.server = server return server -def create_nav( - config: MkapiConfig, - filters: list[str], -) -> tuple[MkapiConfig, list[str]]: - """Create nav.""" - nav = config["nav"] - docs_dir = config["docs_dir"] +def insert_sys_path(config: MkAPIConfig) -> None: # noqa: D103 config_dir = Path(config.config_file_path).parent - abs_api_paths: list[str] = [] - for page in nav: - if isinstance(page, dict): - for key, value in page.items(): - if isinstance(value, str) and value.startswith("mkapi/"): - _ = collect(value, docs_dir, config_dir, filters) - page[key], abs_api_paths_ = _ - abs_api_paths.extend(abs_api_paths_) - return config, abs_api_paths - - -def collect( - path: str, - docs_dir: str, - config_dir: Path, - global_filters: list[str], -) -> tuple[list, list]: - """Collect pages.""" - _, api_path, *paths, package_path = path.split("/") - abs_api_path = Path(docs_dir) / api_path - if abs_api_path.exists(): - msg = f"[MkAPI] {abs_api_path} exists: Delete manually for safety." - logger.error(msg) - sys.exit(1) - Path.mkdir(abs_api_path / "source", parents=True) - atexit.register(lambda path=abs_api_path: rmtree(path)) + for src_dir in config.src_dirs: + if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: + sys.path.insert(0, path) + if not config.src_dirs and (path := Path.cwd()) not in sys.path: + sys.path.insert(0, str(path)) + + +def is_api_entry(item: str | list | dict) -> TypeGuard[str]: # noqa:D103 + return isinstance(item, str) and item.lower().startswith("mkapi/") + + +def _walk_nav(nav: list | dict, create_api_nav: Callable[[str], list]) -> None: + it = enumerate(nav) if isinstance(nav, list) else nav.items() + for k, item in it: + if is_api_entry(item): + api_nav = create_api_nav(item) + nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} + elif isinstance(item, list | dict): + _walk_nav(item, create_api_nav) + - if (root := config_dir.joinpath(*paths)) not in sys.path: - sys.path.insert(0, str(root)) +def update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: + """Update nav.""" + if not isinstance(config.nav, list): + return [] - package_path, filters = split_filters(package_path) - filters = update_filters(global_filters, filters) + def create_api_nav(item: str) -> list: + nav, paths = collect(item, config.docs_dir, filters) + abs_api_paths.extend(paths) + return nav + + abs_api_paths: list[Path] = [] + _walk_nav(config.nav, create_api_nav) + + return abs_api_paths + + +def collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[Path]]: + """Collect modules.""" + _, *api_paths, package_path = item.split("/") + api_path = Path(*api_paths) + abs_api_path = Path(docs_dir) / api_path + Path.mkdir(abs_api_path / "source", parents=True, exist_ok=True) + atexit.register(lambda path=abs_api_path: rmtree(path)) + package_path, filters_ = split_filters(package_path) + filters = update_filters(filters, filters_) + + def add_module(module: Module, package: str | None) -> None: + module_path = module.object.id + ".md" + abs_module_path = abs_api_path / module_path + abs_api_paths.append(abs_module_path) + create_page(abs_module_path, module, filters) + module_name = module.object.id + if package and "short_nav" in filters and module_name != package: + module_name = module_name[len(package) + 1 :] + modules[module_name] = (Path(api_path) / module_path).as_posix() + abs_source_path = abs_api_path / "source" / module_path + create_source_page(abs_source_path, module, filters) - nav = [] abs_api_paths: list[Path] = [] modules: dict[str, str] = {} - package = None - - def add_page(module: Module, package: str | None) -> None: - page_file = module.object.id + ".md" - abs_path = abs_api_path / page_file - abs_api_paths.append(abs_path) - create_page(abs_path, module, filters) - page_name = module.object.id - if package and "short_nav" in filters and page_name != package: - page_name = page_name[len(package) + 1 :] - modules[page_name] = str(Path(api_path) / page_file) - abs_path = abs_api_path / "source" / page_file - create_source_page(abs_path, module, filters) - - module = get_module(package_path) - for m in module: - if m.object.kind == "package": + nav, package = [], None + for module in get_module(package_path): + if module.object.kind == "package": if package and modules: nav.append({package: modules}) - package = m.object.id + package = module.object.id modules.clear() - if m.docstring or any(s.docstring for s in m.members): - add_page(m, package) + if module.docstring or any(m.docstring for m in module.members): + add_module(module, package) else: - add_page(m, package) + add_module(module, package) if package and modules: nav.append({package: modules}) return nav, abs_api_paths +def update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: D103 + if not plugin.server: + plugin.config.abs_api_paths = update_nav(config, plugin.config.filters) + global_config["nav"] = config.nav + global_config["abs_api_paths"] = plugin.config.abs_api_paths + else: + config.nav = global_config["nav"] + plugin.config.abs_api_paths = global_config["abs_api_paths"] + + +def on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: # noqa: D103 + if plugin.config.on_config: + on_config = get_object(plugin.config.on_config) + kwargs, params = {}, inspect.signature(on_config).parameters + if "config" in params: + kwargs["config"] = config + if "plugin" in params: + kwargs["plugin"] = plugin + msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" + logger.info(msg) + config_ = on_config(**kwargs) + if isinstance(config_, MkDocsConfig): + return config_ + return config + + def create_page(path: Path, module: Module, filters: list[str]) -> None: """Create a page.""" with path.open("w") as f: @@ -235,7 +275,11 @@ def create_source_page(path: Path, module: Module, filters: list[str]) -> None: f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") -def clear_prefix(toc, level: int, id_: str = "") -> None: +def clear_prefix( + toc: TableOfContents | list[AnchorLink], + level: int, + id_: str = "", +) -> None: """Clear prefix.""" for toc_item in toc: if toc_item.level >= level and (not id_ or toc_item.title == id_): @@ -243,11 +287,11 @@ def clear_prefix(toc, level: int, id_: str = "") -> None: clear_prefix(toc_item.children, level) -def clean_page_title(page) -> None: +def clean_page_title(page: MkDocsPage) -> None: """Clean page title.""" - title = page.title + title = str(page.title) if title.startswith("![mkapi]("): - page.title = title[9:-1].split("|")[0] + page.title = title[9:-1].split("|")[0] # type: ignore # noqa: PGH003 def rmtree(path: Path) -> None: diff --git a/tests/core/test_core_abc.py b/tests/core/test_core_node_abc.py similarity index 89% rename from tests/core/test_core_abc.py rename to tests/core/test_core_node_abc.py index 33b7655e..3ae2934c 100644 --- a/tests/core/test_core_abc.py +++ b/tests/core/test_core_node_abc.py @@ -7,14 +7,14 @@ class C(ABC): """Abstract class.""" - def method(self): + def method(self): # noqa: B027 """Method.""" - @classmethod + @classmethod # noqa: B027 def class_method(cls): """Classmethod.""" - @staticmethod + @staticmethod # noqa: B027 def static_method(): """Staticmethod.""" @@ -55,8 +55,7 @@ def test_get_sourcefiles(): def test_abc(): node = get_node(C) - # assert len(node.members) == 8 - node.object.kind == "abstract class" + assert node.object.kind == "abstract class" for member in node.members: obj = member.object assert obj.kind.replace(" ", "") == obj.name.replace("_", "") diff --git a/tests/inspect/test_inspect_attribute.py b/tests/inspect/test_inspect_attribute.py index 8b06334c..1136fb2b 100644 --- a/tests/inspect/test_inspect_attribute.py +++ b/tests/inspect/test_inspect_attribute.py @@ -1,8 +1,13 @@ from dataclasses import dataclass from examples.styles import google -from mkapi.core.base import Base -from mkapi.inspect.attribute import get_attributes, get_description, getsource_dedent +from mkapi.core.base import Base, Item +from mkapi.inspect.attribute import ( + get_attributes, + get_dataclass_attributes, + get_description, + getsource_dedent, +) from mkapi.inspect.typing import type_string diff --git a/tests/inspect/test_inspect_signature.py b/tests/inspect/test_inspect_signature.py index 88b9b591..901e4f7f 100644 --- a/tests/inspect/test_inspect_signature.py +++ b/tests/inspect/test_inspect_signature.py @@ -3,7 +3,6 @@ import pytest from mkapi.core.node import Node -from mkapi.core.page import Page from mkapi.inspect.signature import Signature, get_parameters, get_signature diff --git a/tests/inspect/test_inspect_signature_typecheck.py b/tests/inspect/test_inspect_signature_typecheck.py new file mode 100644 index 00000000..8aa9641a --- /dev/null +++ b/tests/inspect/test_inspect_signature_typecheck.py @@ -0,0 +1,13 @@ +import inspect + +from examples.typing import current, future + + +def test_current_annotation(): + signature = inspect.signature(current.func) + assert signature.parameters["x"].annotation is int + + +def test_future_annotation(): + signature = inspect.signature(future.func) + assert signature.parameters["x"].annotation == "int" diff --git a/tests/plugins/test_plugins_mkdocs.py b/tests/plugins/test_plugins_mkdocs.py index 0ec3b739..d75069fe 100644 --- a/tests/plugins/test_plugins_mkdocs.py +++ b/tests/plugins/test_plugins_mkdocs.py @@ -1,65 +1,83 @@ -import os -import shutil -import subprocess from pathlib import Path import pytest +from jinja2.environment import Environment +from mkdocs.commands.build import build from mkdocs.config import load_config -from mkdocs.structure.files import get_files -from mkdocs.structure.nav import get_navigation +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import PluginCollection +from mkdocs.theme import Theme -import mkapi +from mkapi.plugins.mkdocs import MkAPIConfig, MkAPIPlugin @pytest.fixture(scope="module") def config_file(): - root = Path(mkapi.__file__).parent.parent - config_file = root / "mkdocs.yml" - return os.path.normpath(config_file) + return Path(__file__).parent.parent.parent / "examples" / "mkdocs.yml" -@pytest.fixture(scope="module") -def config(config_file): - config = load_config(config_file) - plugin = config["plugins"]["mkapi"] - return plugin.on_config(config) +def test_config_file_exists(config_file: Path): + assert config_file.exists() @pytest.fixture(scope="module") -def plugin(config): - return config["plugins"]["mkapi"] +def mkdocs_config(config_file: Path): + return load_config(str(config_file)) + + +def test_mkdocs_config(mkdocs_config: MkDocsConfig): + config = mkdocs_config + assert isinstance(config, MkDocsConfig) + path = Path(config.config_file_path) + assert path.as_posix().endswith("mkapi/examples/mkdocs.yml") + assert config.site_name == "Doc for CI" + assert Path(config.docs_dir) == path.parent / "docs" + assert Path(config.site_dir) == path.parent / "site" + assert config.nav[0] == "index.md" # type: ignore + assert isinstance(config.plugins, PluginCollection) + assert isinstance(config.plugins["mkapi"], MkAPIPlugin) + assert config.pages is None + assert isinstance(config.theme, Theme) + assert config.theme.name == "mkdocs" + assert isinstance(config.theme.get_env(), Environment) + assert config.extra_css == ["custom.css"] + assert str(config.extra_javascript[0]).endswith("tex-mml-chtml.js") + assert "pymdownx.arithmatex" in config.markdown_extensions @pytest.fixture(scope="module") -def env(config): - return config["theme"].get_env() +def mkapi_plugin(mkdocs_config: MkDocsConfig): + return mkdocs_config.plugins["mkapi"] -@pytest.fixture(scope="module") -def files(config, plugin, env): - files = get_files(config) - files.add_files_from_theme(env, config) - return plugin.on_files(files, config) +def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): + assert mkapi_plugin.server is None + assert isinstance(mkapi_plugin.config, MkAPIConfig) @pytest.fixture(scope="module") -def nav(config, files): - return get_navigation(files, config) - +def mkapi_config(mkapi_plugin: MkAPIPlugin): + return mkapi_plugin.config -def test_plugins_mkdocs_config_file(config_file): - assert Path(config_file).exists() +def test_mkapi_config(mkapi_config: MkAPIConfig): + config = mkapi_config + x = ["src_dirs", "on_config", "filters", "callback", "abs_api_paths", "pages"] + assert list(config) == x + assert config.src_dirs == ["."] + assert config.on_config == "custom.on_config" -def test_plugins_mkdocs_config(config): - assert "mkapi" in config["plugins"] - -def test_plugins_mkdocs_build(): - def run(command: str): - args = command.split() - assert subprocess.run(args, check=False).returncode == 0 # noqa: S603 - - if Path("docs/api").exists(): - shutil.rmtree("docs/api") - run("mkdocs build") +@pytest.fixture(scope="module") +def env(mkdocs_config: MkDocsConfig): + return mkdocs_config.theme.get_env() + + +def test_mkdocs_build(mkdocs_config: MkDocsConfig): + config = mkdocs_config + config.plugins.on_startup(command="build", dirty=False) + try: + build(config, dirty=False) + assert True + finally: + config.plugins.on_shutdown() From b4a11b025c84ddbaf1c4effef6f77252833e0a25 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 29 Dec 2023 20:53:31 +0900 Subject: [PATCH 031/148] Copy --- {src => src_old}/mkapi/__about__.py | 0 {src => src_old}/mkapi/__init__.py | 0 {src => src_old}/mkapi/core/__init__.py | 0 {src => src_old}/mkapi/core/base.py | 0 {src => src_old}/mkapi/core/code.py | 0 {src => src_old}/mkapi/core/docstring.py | 0 {src => src_old}/mkapi/core/filter.py | 0 {src => src_old}/mkapi/core/inherit.py | 0 {src => src_old}/mkapi/core/link.py | 0 {src => src_old}/mkapi/core/module.py | 0 {src => src_old}/mkapi/core/node.py | 0 {src => src_old}/mkapi/core/object.py | 0 {src => src_old}/mkapi/core/page.py | 0 {src => src_old}/mkapi/core/postprocess.py | 0 {src => src_old}/mkapi/core/preprocess.py | 0 {src => src_old}/mkapi/core/renderer.py | 0 {src => src_old}/mkapi/core/structure.py | 0 {src => src_old}/mkapi/inspect/__init__.py | 0 {src => src_old}/mkapi/inspect/attribute.py | 0 {src => src_old}/mkapi/inspect/signature.py | 0 {src => src_old}/mkapi/inspect/typing.py | 0 {src => src_old}/mkapi/main.py | 0 {src => src_old}/mkapi/plugins/__init__.py | 0 {src => src_old}/mkapi/plugins/mkdocs.py | 0 {src => src_old}/mkapi/templates/bases.jinja2 | 0 {src => src_old}/mkapi/templates/code.jinja2 | 0 .../mkapi/templates/docstring.jinja2 | 0 {src => src_old}/mkapi/templates/items.jinja2 | 0 .../mkapi/templates/macros.jinja2 | 0 .../mkapi/templates/member.jinja2 | 0 .../mkapi/templates/module.jinja2 | 0 {src => src_old}/mkapi/templates/node.jinja2 | 0 .../mkapi/templates/object.jinja2 | 0 .../mkapi/theme/css/mkapi-common.css | 0 .../mkapi/theme/css/mkapi-ivory.css | 0 .../mkapi/theme/css/mkapi-mkdocs.css | 0 .../mkapi/theme/css/mkapi-readthedocs.css | 0 {src => src_old}/mkapi/theme/js/mkapi.js | 0 {src => src_old}/mkapi/theme/mkapi.yml | 0 {tests => tests_old}/__init__.py | 0 {tests => tests_old}/conftest.py | 0 {tests => tests_old}/core/test_core_base.py | 0 {tests => tests_old}/core/test_core_code.py | 0 .../core/test_core_docstring.py | 0 .../core/test_core_docstring_from_text.py | 0 .../core/test_core_inherit.py | 0 {tests => tests_old}/core/test_core_link.py | 0 {tests => tests_old}/core/test_core_module.py | 0 {tests => tests_old}/core/test_core_node.py | 0 .../core/test_core_node_abc.py | 0 .../core/test_core_node_init.py | 0 {tests => tests_old}/core/test_core_object.py | 0 {tests => tests_old}/core/test_core_page.py | 0 .../core/test_core_postprocess.py | 0 .../core/test_core_preprocess.py | 0 .../core/test_core_renderer.py | 0 {tests => tests_old}/core/test_mro.py | 0 .../inspect/test_inspect_attribute.py | 21 ++++++++++++------- .../inspect/test_inspect_signature.py | 0 .../test_inspect_signature_typecheck.py | 0 .../inspect/test_inspect_typing.py | 0 .../plugins/test_plugins_mkdocs.py | 0 62 files changed, 14 insertions(+), 7 deletions(-) rename {src => src_old}/mkapi/__about__.py (100%) rename {src => src_old}/mkapi/__init__.py (100%) rename {src => src_old}/mkapi/core/__init__.py (100%) rename {src => src_old}/mkapi/core/base.py (100%) rename {src => src_old}/mkapi/core/code.py (100%) rename {src => src_old}/mkapi/core/docstring.py (100%) rename {src => src_old}/mkapi/core/filter.py (100%) rename {src => src_old}/mkapi/core/inherit.py (100%) rename {src => src_old}/mkapi/core/link.py (100%) rename {src => src_old}/mkapi/core/module.py (100%) rename {src => src_old}/mkapi/core/node.py (100%) rename {src => src_old}/mkapi/core/object.py (100%) rename {src => src_old}/mkapi/core/page.py (100%) rename {src => src_old}/mkapi/core/postprocess.py (100%) rename {src => src_old}/mkapi/core/preprocess.py (100%) rename {src => src_old}/mkapi/core/renderer.py (100%) rename {src => src_old}/mkapi/core/structure.py (100%) rename {src => src_old}/mkapi/inspect/__init__.py (100%) rename {src => src_old}/mkapi/inspect/attribute.py (100%) rename {src => src_old}/mkapi/inspect/signature.py (100%) rename {src => src_old}/mkapi/inspect/typing.py (100%) rename {src => src_old}/mkapi/main.py (100%) rename {src => src_old}/mkapi/plugins/__init__.py (100%) rename {src => src_old}/mkapi/plugins/mkdocs.py (100%) rename {src => src_old}/mkapi/templates/bases.jinja2 (100%) rename {src => src_old}/mkapi/templates/code.jinja2 (100%) rename {src => src_old}/mkapi/templates/docstring.jinja2 (100%) rename {src => src_old}/mkapi/templates/items.jinja2 (100%) rename {src => src_old}/mkapi/templates/macros.jinja2 (100%) rename {src => src_old}/mkapi/templates/member.jinja2 (100%) rename {src => src_old}/mkapi/templates/module.jinja2 (100%) rename {src => src_old}/mkapi/templates/node.jinja2 (100%) rename {src => src_old}/mkapi/templates/object.jinja2 (100%) rename {src => src_old}/mkapi/theme/css/mkapi-common.css (100%) rename {src => src_old}/mkapi/theme/css/mkapi-ivory.css (100%) rename {src => src_old}/mkapi/theme/css/mkapi-mkdocs.css (100%) rename {src => src_old}/mkapi/theme/css/mkapi-readthedocs.css (100%) rename {src => src_old}/mkapi/theme/js/mkapi.js (100%) rename {src => src_old}/mkapi/theme/mkapi.yml (100%) rename {tests => tests_old}/__init__.py (100%) rename {tests => tests_old}/conftest.py (100%) rename {tests => tests_old}/core/test_core_base.py (100%) rename {tests => tests_old}/core/test_core_code.py (100%) rename {tests => tests_old}/core/test_core_docstring.py (100%) rename {tests => tests_old}/core/test_core_docstring_from_text.py (100%) rename {tests => tests_old}/core/test_core_inherit.py (100%) rename {tests => tests_old}/core/test_core_link.py (100%) rename {tests => tests_old}/core/test_core_module.py (100%) rename {tests => tests_old}/core/test_core_node.py (100%) rename {tests => tests_old}/core/test_core_node_abc.py (100%) rename {tests => tests_old}/core/test_core_node_init.py (100%) rename {tests => tests_old}/core/test_core_object.py (100%) rename {tests => tests_old}/core/test_core_page.py (100%) rename {tests => tests_old}/core/test_core_postprocess.py (100%) rename {tests => tests_old}/core/test_core_preprocess.py (100%) rename {tests => tests_old}/core/test_core_renderer.py (100%) rename {tests => tests_old}/core/test_mro.py (100%) rename {tests => tests_old}/inspect/test_inspect_attribute.py (93%) rename {tests => tests_old}/inspect/test_inspect_signature.py (100%) rename {tests => tests_old}/inspect/test_inspect_signature_typecheck.py (100%) rename {tests => tests_old}/inspect/test_inspect_typing.py (100%) rename {tests => tests_old}/plugins/test_plugins_mkdocs.py (100%) diff --git a/src/mkapi/__about__.py b/src_old/mkapi/__about__.py similarity index 100% rename from src/mkapi/__about__.py rename to src_old/mkapi/__about__.py diff --git a/src/mkapi/__init__.py b/src_old/mkapi/__init__.py similarity index 100% rename from src/mkapi/__init__.py rename to src_old/mkapi/__init__.py diff --git a/src/mkapi/core/__init__.py b/src_old/mkapi/core/__init__.py similarity index 100% rename from src/mkapi/core/__init__.py rename to src_old/mkapi/core/__init__.py diff --git a/src/mkapi/core/base.py b/src_old/mkapi/core/base.py similarity index 100% rename from src/mkapi/core/base.py rename to src_old/mkapi/core/base.py diff --git a/src/mkapi/core/code.py b/src_old/mkapi/core/code.py similarity index 100% rename from src/mkapi/core/code.py rename to src_old/mkapi/core/code.py diff --git a/src/mkapi/core/docstring.py b/src_old/mkapi/core/docstring.py similarity index 100% rename from src/mkapi/core/docstring.py rename to src_old/mkapi/core/docstring.py diff --git a/src/mkapi/core/filter.py b/src_old/mkapi/core/filter.py similarity index 100% rename from src/mkapi/core/filter.py rename to src_old/mkapi/core/filter.py diff --git a/src/mkapi/core/inherit.py b/src_old/mkapi/core/inherit.py similarity index 100% rename from src/mkapi/core/inherit.py rename to src_old/mkapi/core/inherit.py diff --git a/src/mkapi/core/link.py b/src_old/mkapi/core/link.py similarity index 100% rename from src/mkapi/core/link.py rename to src_old/mkapi/core/link.py diff --git a/src/mkapi/core/module.py b/src_old/mkapi/core/module.py similarity index 100% rename from src/mkapi/core/module.py rename to src_old/mkapi/core/module.py diff --git a/src/mkapi/core/node.py b/src_old/mkapi/core/node.py similarity index 100% rename from src/mkapi/core/node.py rename to src_old/mkapi/core/node.py diff --git a/src/mkapi/core/object.py b/src_old/mkapi/core/object.py similarity index 100% rename from src/mkapi/core/object.py rename to src_old/mkapi/core/object.py diff --git a/src/mkapi/core/page.py b/src_old/mkapi/core/page.py similarity index 100% rename from src/mkapi/core/page.py rename to src_old/mkapi/core/page.py diff --git a/src/mkapi/core/postprocess.py b/src_old/mkapi/core/postprocess.py similarity index 100% rename from src/mkapi/core/postprocess.py rename to src_old/mkapi/core/postprocess.py diff --git a/src/mkapi/core/preprocess.py b/src_old/mkapi/core/preprocess.py similarity index 100% rename from src/mkapi/core/preprocess.py rename to src_old/mkapi/core/preprocess.py diff --git a/src/mkapi/core/renderer.py b/src_old/mkapi/core/renderer.py similarity index 100% rename from src/mkapi/core/renderer.py rename to src_old/mkapi/core/renderer.py diff --git a/src/mkapi/core/structure.py b/src_old/mkapi/core/structure.py similarity index 100% rename from src/mkapi/core/structure.py rename to src_old/mkapi/core/structure.py diff --git a/src/mkapi/inspect/__init__.py b/src_old/mkapi/inspect/__init__.py similarity index 100% rename from src/mkapi/inspect/__init__.py rename to src_old/mkapi/inspect/__init__.py diff --git a/src/mkapi/inspect/attribute.py b/src_old/mkapi/inspect/attribute.py similarity index 100% rename from src/mkapi/inspect/attribute.py rename to src_old/mkapi/inspect/attribute.py diff --git a/src/mkapi/inspect/signature.py b/src_old/mkapi/inspect/signature.py similarity index 100% rename from src/mkapi/inspect/signature.py rename to src_old/mkapi/inspect/signature.py diff --git a/src/mkapi/inspect/typing.py b/src_old/mkapi/inspect/typing.py similarity index 100% rename from src/mkapi/inspect/typing.py rename to src_old/mkapi/inspect/typing.py diff --git a/src/mkapi/main.py b/src_old/mkapi/main.py similarity index 100% rename from src/mkapi/main.py rename to src_old/mkapi/main.py diff --git a/src/mkapi/plugins/__init__.py b/src_old/mkapi/plugins/__init__.py similarity index 100% rename from src/mkapi/plugins/__init__.py rename to src_old/mkapi/plugins/__init__.py diff --git a/src/mkapi/plugins/mkdocs.py b/src_old/mkapi/plugins/mkdocs.py similarity index 100% rename from src/mkapi/plugins/mkdocs.py rename to src_old/mkapi/plugins/mkdocs.py diff --git a/src/mkapi/templates/bases.jinja2 b/src_old/mkapi/templates/bases.jinja2 similarity index 100% rename from src/mkapi/templates/bases.jinja2 rename to src_old/mkapi/templates/bases.jinja2 diff --git a/src/mkapi/templates/code.jinja2 b/src_old/mkapi/templates/code.jinja2 similarity index 100% rename from src/mkapi/templates/code.jinja2 rename to src_old/mkapi/templates/code.jinja2 diff --git a/src/mkapi/templates/docstring.jinja2 b/src_old/mkapi/templates/docstring.jinja2 similarity index 100% rename from src/mkapi/templates/docstring.jinja2 rename to src_old/mkapi/templates/docstring.jinja2 diff --git a/src/mkapi/templates/items.jinja2 b/src_old/mkapi/templates/items.jinja2 similarity index 100% rename from src/mkapi/templates/items.jinja2 rename to src_old/mkapi/templates/items.jinja2 diff --git a/src/mkapi/templates/macros.jinja2 b/src_old/mkapi/templates/macros.jinja2 similarity index 100% rename from src/mkapi/templates/macros.jinja2 rename to src_old/mkapi/templates/macros.jinja2 diff --git a/src/mkapi/templates/member.jinja2 b/src_old/mkapi/templates/member.jinja2 similarity index 100% rename from src/mkapi/templates/member.jinja2 rename to src_old/mkapi/templates/member.jinja2 diff --git a/src/mkapi/templates/module.jinja2 b/src_old/mkapi/templates/module.jinja2 similarity index 100% rename from src/mkapi/templates/module.jinja2 rename to src_old/mkapi/templates/module.jinja2 diff --git a/src/mkapi/templates/node.jinja2 b/src_old/mkapi/templates/node.jinja2 similarity index 100% rename from src/mkapi/templates/node.jinja2 rename to src_old/mkapi/templates/node.jinja2 diff --git a/src/mkapi/templates/object.jinja2 b/src_old/mkapi/templates/object.jinja2 similarity index 100% rename from src/mkapi/templates/object.jinja2 rename to src_old/mkapi/templates/object.jinja2 diff --git a/src/mkapi/theme/css/mkapi-common.css b/src_old/mkapi/theme/css/mkapi-common.css similarity index 100% rename from src/mkapi/theme/css/mkapi-common.css rename to src_old/mkapi/theme/css/mkapi-common.css diff --git a/src/mkapi/theme/css/mkapi-ivory.css b/src_old/mkapi/theme/css/mkapi-ivory.css similarity index 100% rename from src/mkapi/theme/css/mkapi-ivory.css rename to src_old/mkapi/theme/css/mkapi-ivory.css diff --git a/src/mkapi/theme/css/mkapi-mkdocs.css b/src_old/mkapi/theme/css/mkapi-mkdocs.css similarity index 100% rename from src/mkapi/theme/css/mkapi-mkdocs.css rename to src_old/mkapi/theme/css/mkapi-mkdocs.css diff --git a/src/mkapi/theme/css/mkapi-readthedocs.css b/src_old/mkapi/theme/css/mkapi-readthedocs.css similarity index 100% rename from src/mkapi/theme/css/mkapi-readthedocs.css rename to src_old/mkapi/theme/css/mkapi-readthedocs.css diff --git a/src/mkapi/theme/js/mkapi.js b/src_old/mkapi/theme/js/mkapi.js similarity index 100% rename from src/mkapi/theme/js/mkapi.js rename to src_old/mkapi/theme/js/mkapi.js diff --git a/src/mkapi/theme/mkapi.yml b/src_old/mkapi/theme/mkapi.yml similarity index 100% rename from src/mkapi/theme/mkapi.yml rename to src_old/mkapi/theme/mkapi.yml diff --git a/tests/__init__.py b/tests_old/__init__.py similarity index 100% rename from tests/__init__.py rename to tests_old/__init__.py diff --git a/tests/conftest.py b/tests_old/conftest.py similarity index 100% rename from tests/conftest.py rename to tests_old/conftest.py diff --git a/tests/core/test_core_base.py b/tests_old/core/test_core_base.py similarity index 100% rename from tests/core/test_core_base.py rename to tests_old/core/test_core_base.py diff --git a/tests/core/test_core_code.py b/tests_old/core/test_core_code.py similarity index 100% rename from tests/core/test_core_code.py rename to tests_old/core/test_core_code.py diff --git a/tests/core/test_core_docstring.py b/tests_old/core/test_core_docstring.py similarity index 100% rename from tests/core/test_core_docstring.py rename to tests_old/core/test_core_docstring.py diff --git a/tests/core/test_core_docstring_from_text.py b/tests_old/core/test_core_docstring_from_text.py similarity index 100% rename from tests/core/test_core_docstring_from_text.py rename to tests_old/core/test_core_docstring_from_text.py diff --git a/tests/core/test_core_inherit.py b/tests_old/core/test_core_inherit.py similarity index 100% rename from tests/core/test_core_inherit.py rename to tests_old/core/test_core_inherit.py diff --git a/tests/core/test_core_link.py b/tests_old/core/test_core_link.py similarity index 100% rename from tests/core/test_core_link.py rename to tests_old/core/test_core_link.py diff --git a/tests/core/test_core_module.py b/tests_old/core/test_core_module.py similarity index 100% rename from tests/core/test_core_module.py rename to tests_old/core/test_core_module.py diff --git a/tests/core/test_core_node.py b/tests_old/core/test_core_node.py similarity index 100% rename from tests/core/test_core_node.py rename to tests_old/core/test_core_node.py diff --git a/tests/core/test_core_node_abc.py b/tests_old/core/test_core_node_abc.py similarity index 100% rename from tests/core/test_core_node_abc.py rename to tests_old/core/test_core_node_abc.py diff --git a/tests/core/test_core_node_init.py b/tests_old/core/test_core_node_init.py similarity index 100% rename from tests/core/test_core_node_init.py rename to tests_old/core/test_core_node_init.py diff --git a/tests/core/test_core_object.py b/tests_old/core/test_core_object.py similarity index 100% rename from tests/core/test_core_object.py rename to tests_old/core/test_core_object.py diff --git a/tests/core/test_core_page.py b/tests_old/core/test_core_page.py similarity index 100% rename from tests/core/test_core_page.py rename to tests_old/core/test_core_page.py diff --git a/tests/core/test_core_postprocess.py b/tests_old/core/test_core_postprocess.py similarity index 100% rename from tests/core/test_core_postprocess.py rename to tests_old/core/test_core_postprocess.py diff --git a/tests/core/test_core_preprocess.py b/tests_old/core/test_core_preprocess.py similarity index 100% rename from tests/core/test_core_preprocess.py rename to tests_old/core/test_core_preprocess.py diff --git a/tests/core/test_core_renderer.py b/tests_old/core/test_core_renderer.py similarity index 100% rename from tests/core/test_core_renderer.py rename to tests_old/core/test_core_renderer.py diff --git a/tests/core/test_mro.py b/tests_old/core/test_mro.py similarity index 100% rename from tests/core/test_mro.py rename to tests_old/core/test_mro.py diff --git a/tests/inspect/test_inspect_attribute.py b/tests_old/inspect/test_inspect_attribute.py similarity index 93% rename from tests/inspect/test_inspect_attribute.py rename to tests_old/inspect/test_inspect_attribute.py index 1136fb2b..656ac4e0 100644 --- a/tests/inspect/test_inspect_attribute.py +++ b/tests_old/inspect/test_inspect_attribute.py @@ -1,13 +1,8 @@ from dataclasses import dataclass from examples.styles import google -from mkapi.core.base import Base, Item -from mkapi.inspect.attribute import ( - get_attributes, - get_dataclass_attributes, - get_description, - getsource_dedent, -) +from mkapi.core.base import Base +from mkapi.inspect.attribute import get_attributes, get_description, getsource_dedent from mkapi.inspect.typing import type_string @@ -88,6 +83,18 @@ def test_dataclass_attribute(): assert type_ is int +def test_dataclass_ast_parse(): + import ast + import inspect + + x = C + s = inspect.getsource(x) + print(s) + n = ast.parse(s) + print(n) + assert 0 + + @dataclass class D: x: int diff --git a/tests/inspect/test_inspect_signature.py b/tests_old/inspect/test_inspect_signature.py similarity index 100% rename from tests/inspect/test_inspect_signature.py rename to tests_old/inspect/test_inspect_signature.py diff --git a/tests/inspect/test_inspect_signature_typecheck.py b/tests_old/inspect/test_inspect_signature_typecheck.py similarity index 100% rename from tests/inspect/test_inspect_signature_typecheck.py rename to tests_old/inspect/test_inspect_signature_typecheck.py diff --git a/tests/inspect/test_inspect_typing.py b/tests_old/inspect/test_inspect_typing.py similarity index 100% rename from tests/inspect/test_inspect_typing.py rename to tests_old/inspect/test_inspect_typing.py diff --git a/tests/plugins/test_plugins_mkdocs.py b/tests_old/plugins/test_plugins_mkdocs.py similarity index 100% rename from tests/plugins/test_plugins_mkdocs.py rename to tests_old/plugins/test_plugins_mkdocs.py From eafd3022fa0d587aeb2a4afc1d7bbb29c8e7003c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 30 Dec 2023 23:09:06 +0900 Subject: [PATCH 032/148] ast --- pyproject.toml | 5 +- src/mkapi/__about__.py | 1 + src/mkapi/__init__.py | 0 src/mkapi/ast.py | 178 +++++++++++++++++++++++++++++++++++++ src/mkapi/modules.py | 91 +++++++++++++++++++ src/mkapi/nodes.py | 17 ++++ src/mkapi/objects.py | 34 +++++++ tests/__init__.py | 0 tests/ast/test_class.py | 45 ++++++++++ tests/ast/test_function.py | 48 ++++++++++ tests/test_ast.py | 96 ++++++++++++++++++++ tests/test_modules.py | 45 ++++++++++ tests/test_nodes.py | 0 13 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 src/mkapi/__about__.py create mode 100644 src/mkapi/__init__.py create mode 100644 src/mkapi/ast.py create mode 100644 src/mkapi/modules.py create mode 100644 src/mkapi/nodes.py create mode 100644 src/mkapi/objects.py create mode 100644 tests/__init__.py create mode 100644 tests/ast/test_class.py create mode 100644 tests/ast/test_function.py create mode 100644 tests/test_ast.py create mode 100644 tests/test_modules.py create mode 100644 tests/test_nodes.py diff --git a/pyproject.toml b/pyproject.toml index 25a2c787..b762a98a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Software Development :: Documentation", ] dynamic = ["version"] -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["jinja2", "markdown", "mkdocs"] [project.urls] @@ -56,6 +56,9 @@ addopts = [ "--cov-report=lcov:lcov.info", ] doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] +filterwarnings = [ + 'ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning', +] [tool.coverage.run] omit = ["src/mkapi/__about__.py"] diff --git a/src/mkapi/__about__.py b/src/mkapi/__about__.py new file mode 100644 index 00000000..d293e3a3 --- /dev/null +++ b/src/mkapi/__about__.py @@ -0,0 +1 @@ +__version__ = "1.2.0" diff --git a/src/mkapi/__init__.py b/src/mkapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py new file mode 100644 index 00000000..b41b8067 --- /dev/null +++ b/src/mkapi/ast.py @@ -0,0 +1,178 @@ +"""AST module.""" +from __future__ import annotations + +import ast +from ast import ( + AnnAssign, + Assign, + AsyncFunctionDef, + Attribute, + ClassDef, + Constant, + Expr, + FunctionDef, + Import, + ImportFrom, + List, + Module, + Name, + Subscript, + Tuple, +) +from inspect import cleandoc +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from ast import AST + from collections.abc import Callable, Iterator, Sequence + + +def iter_import_names(node: AST) -> Iterator[tuple[str | None, str, str | None]]: + """Yield imported names.""" + for child in iter_import_nodes(node): + from_module = None if isinstance(child, Import) else child.module + for alias in child.names: + yield from_module, alias.name, alias.asname + + +def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: + """Yield import nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, Import | ImportFrom): + yield child + elif not isinstance(child, AsyncFunctionDef | ClassDef | FunctionDef): + yield from iter_import_nodes(child) + + +def get_import_names(node: AST) -> list[tuple[str | None, str, str | None]]: + """Return a list of imported names.""" + return list(iter_import_names(node)) + + +Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef + + +def iter_def_nodes(node: AST) -> Iterator[Def]: + """Yield definition nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, Def): + yield child + + +def get_def_nodes(node: AST) -> list[Def]: + """Return a list of definition nodes.""" + return list(iter_def_nodes(node)) + + +Assign_: TypeAlias = Assign | AnnAssign + + +def _get_docstring(node: AST) -> str | None: + if not isinstance(node, Expr) or not isinstance(node.value, Constant): + return None + doc = node.value.value + return cleandoc(doc) if isinstance(doc, str) else None + + +def iter_assign_nodes(node: AST) -> Iterator[Assign_]: + """Yield assign nodes.""" + assign_node: Assign_ | None = None + for child in ast.iter_child_nodes(node): + if isinstance(child, Assign_): + assign_node = child + else: + if assign_node: + assign_node.__doc__ = _get_docstring(child) + yield assign_node + assign_node = None + if assign_node: + assign_node.__doc__ = None + yield assign_node + + +def get_assign_nodes(node: AST) -> list[Assign_]: + """Return a list of assign nodes.""" + return list(iter_assign_nodes(node)) + + +Doc: TypeAlias = Module | Def | Assign_ + + +def get_docstring(node: Doc) -> str | None: + """Return the docstring for the given node or None if no docstring can be found.""" + if isinstance(node, Module | Def): + return ast.get_docstring(node) + if isinstance(node, Assign_): + return node.__doc__ + msg = f"{node.__class__.__name__!r} can't have docstrings" + raise TypeError(msg) + + +def get_name(node: AST) -> str | None: + """Return the node name.""" + if isinstance(node, Def): + return node.name + if isinstance(node, Assign): + for target in node.targets: + if isinstance(target, Name): + return target.id + if isinstance(node, AnnAssign) and isinstance(node.target, Name): + return node.target.id + return None + + +def get_by_name(nodes: Sequence[Def | Assign_], name: str) -> Def | Assign_ | None: + """Return the node that has the name.""" + for node in nodes: + if get_name(node) == name: + return node + return None + + +def parse_attribute(node: Attribute) -> str: # noqa: D103 + return ".".join([parse_node(node.value), node.attr]) + + +def parse_subscript(node: Subscript) -> str: # noqa: D103 + value = parse_node(node.value) + slice_ = parse_node(node.slice) + return f"{value}[{slice_}]" + + +def parse_constant(node: Constant) -> str: # noqa: D103 + if node.value is Ellipsis: + return "..." + if isinstance(node.value, str): + return node.value + return parse_value(node.value) + + +def parse_list(node: List) -> str: # noqa: D103 + return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" + + +def parse_tuple(node: Tuple) -> str: # noqa: D103 + return ", ".join(parse_node(n) for n in node.elts) + + +def parse_value(value: Any) -> str: # noqa: D103, ANN401 + return str(value) + + +PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ + (Attribute, parse_attribute), + (Subscript, parse_subscript), + (Constant, parse_constant), + (List, parse_list), + (Tuple, parse_tuple), + (Name, "id"), +] + + +def parse_node(node: AST) -> str: + """Return the string expression for an AST node.""" + for type_, parse in PARSE_NODE_FUNCTIONS: + if isinstance(node, type_): + node_str = parse(node) if callable(parse) else getattr(node, parse) + return node_str if isinstance(node_str, str) else str(node_str) + return ast.unparse(node) diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py new file mode 100644 index 00000000..682b00de --- /dev/null +++ b/src/mkapi/modules.py @@ -0,0 +1,91 @@ +"""Modules.""" +from __future__ import annotations + +import importlib +import inspect +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + from types import ModuleType + + +@dataclass +class Module: + """Module class.""" + + name: str # Qualified module name. + obj: ModuleType # Module object. + path: Path # Absolute source file path. + source: str # Source text. + mtime: float # Modified time. + members: dict[str, Any] # Members of the module. + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"{class_name}({self.name!r})" + + def __getitem__(self, key: str) -> Any: # noqa: ANN401 + return self.members[key] + + def is_package(self) -> bool: + """Return True if the module is a package.""" + return self.path.stem == "__init__" + + def update(self) -> None: + """Update contents.""" + + +def get_members(module: ModuleType) -> dict[str, Any]: + """Return all members of an object as a (name => value) dictonary.""" + members = {} + for name, member in inspect.getmembers(module): + # if not name.startswith("_"): + # members[name] = member + members[name] = member + return members + + +def get_module(name: str) -> Module: + """Return a [Module] instance by name.""" + obj = importlib.import_module(name) + sourcefile = inspect.getsourcefile(obj) + if not sourcefile: + raise NotImplementedError + path = Path(sourcefile) + source = inspect.getsource(obj) + members = get_members(obj) + mtime = path.stat().st_mtime + return Module(name, obj, path, source, mtime, members) + + +def _walk(path: Path) -> Iterator[Path]: + for dirpath, dirnames, filenames in os.walk(path): + if "__init__.py" not in filenames: + dirnames.clear() + else: + yield Path(dirpath) + dirnames[:] = [x for x in dirnames if x != "__pycache__"] + for filename in filenames: + if not filename.startswith("__") and Path(filename).suffix == ".py": + yield Path(dirpath) / Path(filename).stem + + +def _path_to_name(root: Path, name: str) -> Iterator[str]: + for path in _walk(root): + yield ".".join((name, *path.relative_to(root).parts)) + + +def get_modulenames(name: str) -> list[str]: + """Yield submodule names from the package name.""" + module = importlib.import_module(name) + sourcefile = inspect.getsourcefile(module) + if not sourcefile: + return [] + path = Path(sourcefile) + if path.name != "__init__.py": + return [name] + return sorted(_path_to_name(path.parent, name)) diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py new file mode 100644 index 00000000..44df0e2a --- /dev/null +++ b/src/mkapi/nodes.py @@ -0,0 +1,17 @@ +"""Node module.""" +from __future__ import annotations + +import ast +import importlib +import inspect +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + from types import ModuleType + +# @dataclass +# class AST diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py new file mode 100644 index 00000000..d94bd08d --- /dev/null +++ b/src/mkapi/objects.py @@ -0,0 +1,34 @@ +"""Objects.""" +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Object: + """Object class.""" + + name: str # Qualified module name. + obj: object # Object. + members: dict[str, Any] # Members of the object. + + def __post_init__(self) -> None: + pass + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"{class_name}({self.name})" + + def update(self) -> None: + """Update contents.""" + + +def get_members(obj: object) -> dict[str, Any]: + """Return pulblic members of an object as a (name => value) dictionary.""" + members = {} + for name, member in inspect.getmembers(obj): + if not name.startswith("_"): + members[name] = member + return members diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ast/test_class.py b/tests/ast/test_class.py new file mode 100644 index 00000000..3e6403aa --- /dev/null +++ b/tests/ast/test_class.py @@ -0,0 +1,45 @@ +import ast +import inspect + + +def test_class(): + src = """ + class C(B): + '''docstring.''' + a:str # attr a. + b:str='c' + '''attr b. + + ade + ''' + c + """ + node = ast.parse(src := inspect.cleandoc(src)).body[0] + assert isinstance(node, ast.ClassDef) + assert node.name == "C" + assert isinstance(bases := node.bases, list) + assert isinstance(bases[0], ast.Name) + assert bases[0].id == "B" + assert isinstance(body := node.body, list) + assert isinstance(body[0], ast.Expr) + assert isinstance(c := body[0].value, ast.Constant) + assert c.value == "docstring." + assert isinstance(a := body[1], ast.AnnAssign) + assert a.target.id == "a" # type: ignore + assert a.value is None + assert a.annotation.id == "str" # type: ignore + assert a.lineno == 3 + assert (a.lineno, a.col_offset, a.end_col_offset) == (3, 4, 9) + line = src.split("\n")[2] + assert line[4:9] == "a:str" + assert line[9:] == " # attr a." + assert isinstance(a := body[2], ast.AnnAssign) + assert a.target.id == "b" # type: ignore + assert a.value.value == "c" # type: ignore + assert isinstance(body[3], ast.Expr) + assert isinstance(c := body[3].value, ast.Constant) + assert inspect.cleandoc(c.value) == "attr b.\n\nade" + assert isinstance(body[4], ast.Expr) + assert isinstance(body[4].value, ast.Name) + assert body[4].value.id == "c" + assert body[4].lineno == 9 diff --git a/tests/ast/test_function.py b/tests/ast/test_function.py new file mode 100644 index 00000000..addbfa87 --- /dev/null +++ b/tests/ast/test_function.py @@ -0,0 +1,48 @@ +import ast +import inspect + + +def test_function(): + src = """ + def f(a,b:str,/,c:list[int],d:tuple[int,...]="x",*,e=4)->float: + '''docstring.''' + return 1.0 + """ + node = ast.parse(inspect.cleandoc(src)).body[0] + assert isinstance(node, ast.FunctionDef) + assert node.name == "f" + assert isinstance(args := node.args.posonlyargs, list) + assert len(args) == 2 + assert isinstance(args[0], ast.arg) + assert args[0].arg == "a" + assert args[0].annotation is None + assert args[1].arg == "b" + assert isinstance(ann := args[1].annotation, ast.Name) + assert ann.id == "str" + assert isinstance(args := node.args.args, list) + assert len(args) == 2 + assert isinstance(args[0], ast.arg) + assert args[0].arg == "c" + assert isinstance(ann := args[0].annotation, ast.Subscript) + assert isinstance(ann.value, ast.Name) + assert ann.value.id == "list" + assert isinstance(ann.slice, ast.Name) + assert ann.slice.id == "int" + assert args[1].arg == "d" + assert isinstance(ann := args[1].annotation, ast.Subscript) + assert isinstance(ann.value, ast.Name) + assert ann.value.id == "tuple" + assert isinstance(ann.slice, ast.Tuple) + assert isinstance(elts := ann.slice.elts, list) + assert isinstance(elts[0], ast.Name) + assert isinstance(elts[1], ast.Constant) + assert elts[1].value is Ellipsis + assert node.args.vararg is None + assert isinstance(args := node.args.kwonlyargs, list) + assert isinstance(args[0], ast.arg) + assert node.args.kwarg is None + assert node.args.defaults[0].value == "x" # type: ignore + assert node.args.kw_defaults[0].value == 4 # type: ignore + assert isinstance(r := node.returns, ast.Name) + assert r.id == "float" + assert ast.get_docstring(node) == "docstring." diff --git a/tests/test_ast.py b/tests/test_ast.py new file mode 100644 index 00000000..14a75ff6 --- /dev/null +++ b/tests/test_ast.py @@ -0,0 +1,96 @@ +import ast + +import pytest + +from mkapi.ast import ( + get_assign_nodes, + get_by_name, + get_def_nodes, + get_docstring, + get_import_names, + get_name, + iter_import_nodes, +) +from mkapi.modules import get_module + + +@pytest.fixture(scope="module") +def source(): + return get_module("mkdocs.structure.files").source + + +@pytest.fixture(scope="module") +def module(source): + return ast.parse(source, type_comments=True) + + +def test_iter_import_nodes(module): + node = next(iter_import_nodes(module)) + assert isinstance(node, ast.ImportFrom) + assert len(node.names) == 1 + + alias = node.names[0] + assert node.module == "__future__" + assert alias.name == "annotations" + assert alias.asname is None + + +def test_get_import_names(module): + names = get_import_names(module) + assert (None, "logging", None) in names + assert ("pathlib", "PurePath", None) in names + assert ("urllib.parse", "quote", "urlquote") in names + + +@pytest.fixture(scope="module") +def def_nodes(module): + return get_def_nodes(module) + + +def test_get_def_nodes(def_nodes): + assert any(node.name == "get_files" for node in def_nodes) + assert any(node.name == "Files" for node in def_nodes) + + +def test_get_by_name(def_nodes): + node = get_by_name(def_nodes, "get_files") + assert isinstance(node, ast.FunctionDef) + assert node.name == "get_files" + node = get_by_name(def_nodes, "InclusionLevel") + assert isinstance(node, ast.ClassDef) + assert get_name(node) == "InclusionLevel" + nodes = get_assign_nodes(node) + node = get_by_name(nodes, "EXCLUDED") + assert isinstance(node, ast.Assign) + assert get_name(node) == "EXCLUDED" + + +def test_get_docstring(def_nodes): + node = get_by_name(def_nodes, "get_files") + assert isinstance(node, ast.FunctionDef) + doc = get_docstring(node) + assert isinstance(doc, str) + assert doc.startswith("Walk the `docs_dir`") + with pytest.raises(TypeError): + get_docstring(node.args) # type: ignore + node = get_by_name(def_nodes, "InclusionLevel") + assert isinstance(node, ast.ClassDef) + nodes = get_assign_nodes(node) + node = get_by_name(nodes, "INCLUDED") + doc = get_docstring(node) # type: ignore + assert isinstance(doc, str) + assert doc.startswith("The file is part of the site.") + + +# def test_source(source): +# print(source) +# assert 0 + + +# def test_get_assign_nodes(def_nodes): +# for node in def_nodes: +# if node.name == "InclusionLevel": +# nodes = get_assign_nodes(node) +# for node +# print(len(nodes)) +# assert 0 diff --git a/tests/test_modules.py b/tests/test_modules.py new file mode 100644 index 00000000..de120305 --- /dev/null +++ b/tests/test_modules.py @@ -0,0 +1,45 @@ +import importlib +import inspect + +from mkapi.modules import get_members, get_module, get_modulenames + + +def test_get_modulenames(): + names = list(get_modulenames("mkdocs.structure.files")) + assert len(names) == 1 + names = list(get_modulenames("mkdocs")) + module = None + for name in names: + module = importlib.import_module(name) + assert module is not None + sourcefile = inspect.getsourcefile(module) + assert sourcefile is not None + assert importlib.import_module(names[-1]) is module + + +def test_get_members(): + module = importlib.import_module("mkapi.modules") + members = get_members(module) + assert "get_module" in members + assert "_walk" in members + assert "TYPE_CHECKING" in members + assert "annotations" in members + + +def test_members_id(): + module = importlib.import_module("mkapi.modules") + a = get_members(module) + module = importlib.import_module("mkapi.objects") + b = get_members(module) + assert a["annotations"] is b["annotations"] + assert id(a["annotations"]) == id(b["annotations"]) + + +def test_get_module(): + package = get_module("mkdocs") + assert package.is_package() + module = get_module("mkdocs.structure.files") + assert not module.is_package() + assert module.mtime > 1703851000 + assert module.path.stem == "files" + assert module["Files"] is module.members["Files"] diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 00000000..e69de29b From db9cb2fa7d2b0fca41180032f8df5ad7aa2eff5a Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 31 Dec 2023 06:11:05 +0900 Subject: [PATCH 033/148] import names --- src/mkapi/ast.py | 32 +++++++--------- src/mkapi/modules.py | 88 +++++++++++++++---------------------------- src/mkapi/nodes.py | 17 --------- src/mkapi/objects.py | 34 ----------------- tests/test_modules.py | 43 +++------------------ tests/test_nodes.py | 0 6 files changed, 50 insertions(+), 164 deletions(-) delete mode 100644 src/mkapi/nodes.py delete mode 100644 src/mkapi/objects.py delete mode 100644 tests/test_nodes.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index b41b8067..3d32fa9e 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -26,6 +26,20 @@ from ast import AST from collections.abc import Callable, Iterator, Sequence +Import_: TypeAlias = Import | ImportFrom +Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef +Assign_: TypeAlias = Assign | AnnAssign +Doc: TypeAlias = Module | Def | Assign_ + + +def iter_import_nodes(node: AST) -> Iterator[Import_]: + """Yield import nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, Import_): + yield child + elif not isinstance(child, Def): + yield from iter_import_nodes(child) + def iter_import_names(node: AST) -> Iterator[tuple[str | None, str, str | None]]: """Yield imported names.""" @@ -35,23 +49,11 @@ def iter_import_names(node: AST) -> Iterator[tuple[str | None, str, str | None]] yield from_module, alias.name, alias.asname -def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: - """Yield import nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, Import | ImportFrom): - yield child - elif not isinstance(child, AsyncFunctionDef | ClassDef | FunctionDef): - yield from iter_import_nodes(child) - - def get_import_names(node: AST) -> list[tuple[str | None, str, str | None]]: """Return a list of imported names.""" return list(iter_import_names(node)) -Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef - - def iter_def_nodes(node: AST) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): @@ -64,9 +66,6 @@ def get_def_nodes(node: AST) -> list[Def]: return list(iter_def_nodes(node)) -Assign_: TypeAlias = Assign | AnnAssign - - def _get_docstring(node: AST) -> str | None: if not isinstance(node, Expr) or not isinstance(node.value, Constant): return None @@ -95,9 +94,6 @@ def get_assign_nodes(node: AST) -> list[Assign_]: return list(iter_assign_nodes(node)) -Doc: TypeAlias = Module | Def | Assign_ - - def get_docstring(node: Doc) -> str | None: """Return the docstring for the given node or None if no docstring can be found.""" if isinstance(node, Module | Def): diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 682b00de..0de97083 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -1,36 +1,30 @@ """Modules.""" from __future__ import annotations -import importlib -import inspect -import os +import ast from dataclasses import dataclass +from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Iterator - from types import ModuleType @dataclass class Module: """Module class.""" - name: str # Qualified module name. - obj: ModuleType # Module object. - path: Path # Absolute source file path. - source: str # Source text. - mtime: float # Modified time. - members: dict[str, Any] # Members of the module. + name: str + path: Path + source: str + mtime: float + node: ast.Module def __repr__(self) -> str: class_name = self.__class__.__name__ return f"{class_name}({self.name!r})" - def __getitem__(self, key: str) -> Any: # noqa: ANN401 - return self.members[key] - def is_package(self) -> bool: """Return True if the module is a package.""" return self.path.stem == "__init__" @@ -39,53 +33,33 @@ def update(self) -> None: """Update contents.""" -def get_members(module: ModuleType) -> dict[str, Any]: - """Return all members of an object as a (name => value) dictonary.""" - members = {} - for name, member in inspect.getmembers(module): - # if not name.startswith("_"): - # members[name] = member - members[name] = member - return members - - def get_module(name: str) -> Module: """Return a [Module] instance by name.""" - obj = importlib.import_module(name) - sourcefile = inspect.getsourcefile(obj) - if not sourcefile: - raise NotImplementedError - path = Path(sourcefile) - source = inspect.getsource(obj) - members = get_members(obj) + spec = find_spec(name) + if not spec or not spec.origin: + raise ModuleNotFoundError + path = Path(spec.origin) + if not path.exists(): + raise ModuleNotFoundError + with path.open(encoding="utf-8") as f: + source = f.read() + node = ast.parse(source) mtime = path.stat().st_mtime - return Module(name, obj, path, source, mtime, members) - - -def _walk(path: Path) -> Iterator[Path]: - for dirpath, dirnames, filenames in os.walk(path): - if "__init__.py" not in filenames: - dirnames.clear() - else: - yield Path(dirpath) - dirnames[:] = [x for x in dirnames if x != "__pycache__"] - for filename in filenames: - if not filename.startswith("__") and Path(filename).suffix == ".py": - yield Path(dirpath) / Path(filename).stem + return Module(name, path, source, mtime, node) -def _path_to_name(root: Path, name: str) -> Iterator[str]: - for path in _walk(root): - yield ".".join((name, *path.relative_to(root).parts)) +def iter_submodules(module: Module) -> Iterator[Module]: + """Yield submodules.""" + spec = find_spec(module.name) + if not spec or not spec.submodule_search_locations: + return + for location in spec.submodule_search_locations: + for path in Path(location).iterdir(): + if path.suffix == ".py": + name = f"{module.name}.{path.stem}" + yield get_module(name) -def get_modulenames(name: str) -> list[str]: - """Yield submodule names from the package name.""" - module = importlib.import_module(name) - sourcefile = inspect.getsourcefile(module) - if not sourcefile: - return [] - path = Path(sourcefile) - if path.name != "__init__.py": - return [name] - return sorted(_path_to_name(path.parent, name)) +def find_submodules(module: Module) -> list[Module]: + """Return a list of submodules.""" + return list(iter_submodules(module)) diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py deleted file mode 100644 index 44df0e2a..00000000 --- a/src/mkapi/nodes.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Node module.""" -from __future__ import annotations - -import ast -import importlib -import inspect -import os -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Iterator - from types import ModuleType - -# @dataclass -# class AST diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py deleted file mode 100644 index d94bd08d..00000000 --- a/src/mkapi/objects.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Objects.""" -from __future__ import annotations - -import inspect -from dataclasses import dataclass -from typing import Any - - -@dataclass -class Object: - """Object class.""" - - name: str # Qualified module name. - obj: object # Object. - members: dict[str, Any] # Members of the object. - - def __post_init__(self) -> None: - pass - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name})" - - def update(self) -> None: - """Update contents.""" - - -def get_members(obj: object) -> dict[str, Any]: - """Return pulblic members of an object as a (name => value) dictionary.""" - members = {} - for name, member in inspect.getmembers(obj): - if not name.startswith("_"): - members[name] = member - return members diff --git a/tests/test_modules.py b/tests/test_modules.py index de120305..567fa2d9 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,45 +1,12 @@ -import importlib -import inspect - -from mkapi.modules import get_members, get_module, get_modulenames - - -def test_get_modulenames(): - names = list(get_modulenames("mkdocs.structure.files")) - assert len(names) == 1 - names = list(get_modulenames("mkdocs")) - module = None - for name in names: - module = importlib.import_module(name) - assert module is not None - sourcefile = inspect.getsourcefile(module) - assert sourcefile is not None - assert importlib.import_module(names[-1]) is module - - -def test_get_members(): - module = importlib.import_module("mkapi.modules") - members = get_members(module) - assert "get_module" in members - assert "_walk" in members - assert "TYPE_CHECKING" in members - assert "annotations" in members - - -def test_members_id(): - module = importlib.import_module("mkapi.modules") - a = get_members(module) - module = importlib.import_module("mkapi.objects") - b = get_members(module) - assert a["annotations"] is b["annotations"] - assert id(a["annotations"]) == id(b["annotations"]) +from mkapi.modules import find_submodules, get_module def test_get_module(): - package = get_module("mkdocs") - assert package.is_package() + module = get_module("mkdocs") + assert module.is_package() + assert len(find_submodules(module)) > 0 module = get_module("mkdocs.structure.files") assert not module.is_package() + assert not find_submodules(module) assert module.mtime > 1703851000 assert module.path.stem == "files" - assert module["Files"] is module.members["Files"] diff --git a/tests/test_nodes.py b/tests/test_nodes.py deleted file mode 100644 index e69de29b..00000000 From 8f81e19283fd2ec31c79612ff7690e38534a8bfa Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 31 Dec 2023 08:04:13 +0900 Subject: [PATCH 034/148] get_names --- src/mkapi/ast.py | 139 +++++++++++++++++++++++++++++++--------------- tests/test_ast.py | 59 ++++++++++++++------ 2 files changed, 136 insertions(+), 62 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 3d32fa9e..5f7ef0f8 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -20,7 +20,7 @@ Tuple, ) from inspect import cleandoc -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias, TypeGuard if TYPE_CHECKING: from ast import AST @@ -41,43 +41,58 @@ def iter_import_nodes(node: AST) -> Iterator[Import_]: yield from iter_import_nodes(child) -def iter_import_names(node: AST) -> Iterator[tuple[str | None, str, str | None]]: +def iter_import_names(node: Module | Def) -> Iterator[tuple[str, str]]: """Yield imported names.""" for child in iter_import_nodes(node): - from_module = None if isinstance(child, Import) else child.module + from_module = f"{child.module}." if isinstance(child, ImportFrom) else "" for alias in child.names: - yield from_module, alias.name, alias.asname + name = alias.asname or alias.name + fullname = f"{from_module}{alias.name}" + yield name, fullname -def get_import_names(node: AST) -> list[tuple[str | None, str, str | None]]: - """Return a list of imported names.""" - return list(iter_import_names(node)) +def get_import_names(node: Module | Def) -> dict[str, str]: + """Return a dictionary of imported names as (name => fullname).""" + return dict(iter_import_names(node)) -def iter_def_nodes(node: AST) -> Iterator[Def]: - """Yield definition nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, Def): - yield child +def _is_assign_name(node: AST) -> TypeGuard[Assign_]: + if isinstance(node, AnnAssign) and isinstance(node.target, Name): + return True + if isinstance(node, Assign) and isinstance(node.targets[0], Name): + return True + return False -def get_def_nodes(node: AST) -> list[Def]: - """Return a list of definition nodes.""" - return list(iter_def_nodes(node)) +def _get_assign_name(node: AST) -> str | None: + """Return the name of the assign node.""" + if isinstance(node, AnnAssign) and isinstance(node.target, Name): + return node.target.id + if isinstance(node, Assign) and isinstance(node.targets[0], Name): + return node.targets[0].id + return None -def _get_docstring(node: AST) -> str | None: - if not isinstance(node, Expr) or not isinstance(node.value, Constant): - return None - doc = node.value.value - return cleandoc(doc) if isinstance(doc, str) else None +def get_name(node: AST) -> str | None: + """Return the node name.""" + if isinstance(node, Def): + return node.name + return _get_assign_name(node) -def iter_assign_nodes(node: AST) -> Iterator[Assign_]: +def get_by_name(nodes: Sequence[Def | Assign_], name: str) -> Def | Assign_ | None: + """Return the node that has the name.""" + for node in nodes: + if get_name(node) == name: + return node + return None + + +def iter_assign_nodes(node: Module | ClassDef) -> Iterator[Assign_]: """Yield assign nodes.""" assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): - if isinstance(child, Assign_): + if _is_assign_name(child): assign_node = child else: if assign_node: @@ -89,11 +104,68 @@ def iter_assign_nodes(node: AST) -> Iterator[Assign_]: yield assign_node -def get_assign_nodes(node: AST) -> list[Assign_]: +def _get_docstring(node: AST) -> str | None: + if not isinstance(node, Expr) or not isinstance(node.value, Constant): + return None + doc = node.value.value + return cleandoc(doc) if isinstance(doc, str) else None + + +def get_assign_nodes(node: Module | ClassDef) -> list[Assign_]: """Return a list of assign nodes.""" return list(iter_assign_nodes(node)) +def iter_assign_names(node: Module | ClassDef) -> Iterator[tuple[str, str | None]]: + """Yield assign node names.""" + for child in iter_assign_nodes(node): + if name := _get_assign_name(child): + fullname = child.value and ast.unparse(child.value) # TODO @D: fix unparse + yield name, fullname + + +def get_assign_names(node: Module | ClassDef) -> dict[str, str | None]: + """Return a dictionary of assigned names as (name => fullname).""" + return dict(iter_assign_names(node)) + + +def iter_def_nodes(node: Module | ClassDef) -> Iterator[Def]: + """Yield definition nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, Def): + yield child + + +def get_def_nodes(node: Module | ClassDef) -> list[Def]: + """Return a list of definition nodes.""" + return list(iter_def_nodes(node)) + + +def iter_def_names(node: Module | ClassDef) -> Iterator[str]: + """Yield definition node names.""" + for child in iter_def_nodes(node): + yield child.name + + +def get_def_names(node: Module | ClassDef) -> list[str]: + """Return a list of definition node names.""" + return list(iter_def_names(node)) + + +def iter_names(node: Module | ClassDef) -> Iterator[tuple[str, str]]: + """Yield import and def names.""" + yield from iter_import_names(node) + for name in iter_def_names(node): + yield name, f".{name}" + for name, _ in iter_assign_names(node): + yield name, f".{name}" + + +def get_names(node: Module | ClassDef) -> dict[str, str]: + """Return a dictionary of import and def names.""" + return dict(iter_names(node)) + + def get_docstring(node: Doc) -> str | None: """Return the docstring for the given node or None if no docstring can be found.""" if isinstance(node, Module | Def): @@ -104,27 +176,6 @@ def get_docstring(node: Doc) -> str | None: raise TypeError(msg) -def get_name(node: AST) -> str | None: - """Return the node name.""" - if isinstance(node, Def): - return node.name - if isinstance(node, Assign): - for target in node.targets: - if isinstance(target, Name): - return target.id - if isinstance(node, AnnAssign) and isinstance(node.target, Name): - return node.target.id - return None - - -def get_by_name(nodes: Sequence[Def | Assign_], name: str) -> Def | Assign_ | None: - """Return the node that has the name.""" - for node in nodes: - if get_name(node) == name: - return node - return None - - def parse_attribute(node: Attribute) -> str: # noqa: D103 return ".".join([parse_node(node.value), node.attr]) diff --git a/tests/test_ast.py b/tests/test_ast.py index 14a75ff6..3a4f5088 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -3,48 +3,47 @@ import pytest from mkapi.ast import ( + get_assign_names, get_assign_nodes, get_by_name, get_def_nodes, get_docstring, get_import_names, get_name, + get_names, iter_import_nodes, ) -from mkapi.modules import get_module +from mkapi.modules import Module, get_module @pytest.fixture(scope="module") -def source(): - return get_module("mkdocs.structure.files").source +def module(): + return get_module("mkdocs.structure.files") -@pytest.fixture(scope="module") -def module(source): - return ast.parse(source, type_comments=True) - - -def test_iter_import_nodes(module): - node = next(iter_import_nodes(module)) +def test_iter_import_nodes(module: Module): + node = next(iter_import_nodes(module.node)) assert isinstance(node, ast.ImportFrom) assert len(node.names) == 1 - alias = node.names[0] assert node.module == "__future__" assert alias.name == "annotations" assert alias.asname is None -def test_get_import_names(module): - names = get_import_names(module) - assert (None, "logging", None) in names - assert ("pathlib", "PurePath", None) in names - assert ("urllib.parse", "quote", "urlquote") in names +def test_get_import_names(module: Module): + names = get_import_names(module.node) + assert "logging" in names + assert names["logging"] == "logging" + assert "PurePath" in names + assert names["PurePath"] == "pathlib.PurePath" + assert "urlquote" in names + assert names["urlquote"] == "urllib.parse.quote" @pytest.fixture(scope="module") -def def_nodes(module): - return get_def_nodes(module) +def def_nodes(module: Module): + return get_def_nodes(module.node) def test_get_def_nodes(def_nodes): @@ -82,6 +81,30 @@ def test_get_docstring(def_nodes): assert doc.startswith("The file is part of the site.") +def test_get_assign_names(module: Module, def_nodes): + names = get_assign_names(module.node) + assert names["log"] is not None + assert names["log"].startswith("logging.getLogger") + node = get_by_name(def_nodes, "InclusionLevel") + assert isinstance(node, ast.ClassDef) + names = get_assign_names(node) + assert names["NOT_IN_NAV"] is not None + assert names["NOT_IN_NAV"] == "-1" + + +def test_get_names(module: Module, def_nodes): + names = get_names(module.node) + assert names["Callable"] == "typing.Callable" + assert names["File"] == ".File" + assert names["get_files"] == ".get_files" + assert names["log"] == ".log" + node = get_by_name(def_nodes, "File") + assert isinstance(node, ast.ClassDef) + names = get_names(node) + assert names["src_path"] == ".src_path" + assert names["url"] == ".url" + + # def test_source(source): # print(source) # assert 0 From 766a713ed9ff2da0ce5d13c30dba141b252e0f4e Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 31 Dec 2023 11:27:53 +0900 Subject: [PATCH 035/148] first plugin --- examples/mkdocs.yml | 8 +- pyproject.toml | 2 +- src/mkapi/config.py | 3 + src/mkapi/filter.py | 44 ++++++ src/mkapi/modules.py | 33 ++++- src/mkapi/objects.py | 32 +++++ src/mkapi/plugins.py | 315 ++++++++++++++++++++++++++++++++++++++++++ tests/test_modules.py | 27 +++- tests/test_plugins.py | 82 +++++++++++ 9 files changed, 535 insertions(+), 11 deletions(-) create mode 100644 src/mkapi/config.py create mode 100644 src/mkapi/filter.py create mode 100644 src/mkapi/objects.py create mode 100644 src/mkapi/plugins.py create mode 100644 tests/test_plugins.py diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml index 19d00a77..d975acdc 100644 --- a/examples/mkdocs.yml +++ b/examples/mkdocs.yml @@ -17,10 +17,10 @@ nav: - index.md - Examples: - example.md - - mkapi/api/mkapi.plugins - - ABC: mkapi/api/mkapi.inspect - - API: mkapi/api/mkapi.inspect|upper|strict - - mkapi/api/mkapi.plugins + - /mkapi.plugins|upper + - ABC: /mkdocs.structure + - API: /mkdocs.structure.files|upper|strict + - /mkapi extra_css: - custom.css extra_javascript: diff --git a/pyproject.toml b/pyproject.toml index b762a98a..d4f8149d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ Issues = "https://github.com/daizutabi/mkapi/issues" mkapi = "mkapi.main:cli" [project.entry-points."mkdocs.plugins"] -mkapi = "mkapi.plugins.mkdocs:MkAPIPlugin" +mkapi = "mkapi.plugins:MkAPIPlugin" [tool.hatch.version] path = "src/mkapi/__about__.py" diff --git a/src/mkapi/config.py b/src/mkapi/config.py new file mode 100644 index 00000000..6dbaca7a --- /dev/null +++ b/src/mkapi/config.py @@ -0,0 +1,3 @@ +"""Configuration file.""" + +exclude = ["/tests"] diff --git a/src/mkapi/filter.py b/src/mkapi/filter.py new file mode 100644 index 00000000..db115974 --- /dev/null +++ b/src/mkapi/filter.py @@ -0,0 +1,44 @@ +"""Filter functions.""" + + +def split_filters(name: str) -> tuple[str, list[str]]: + """Split filters written after `|`s. + + Examples: + >>> split_filters("a.b.c") + ('a.b.c', []) + >>> split_filters("a.b.c|upper|strict") + ('a.b.c', ['upper', 'strict']) + >>> split_filters("|upper|strict") + ('', ['upper', 'strict']) + >>> split_filters("") + ('', []) + """ + index = name.find("|") + if index == -1: + return name, [] + name, filters = name[:index], name[index + 1 :] + return name, filters.split("|") + + +def update_filters(org: list[str], update: list[str]) -> list[str]: + """Update filters. + + Examples: + >>> update_filters(['upper'], ['lower']) + ['lower'] + >>> update_filters(['lower'], ['upper']) + ['upper'] + >>> update_filters(['long'], ['short']) + ['short'] + >>> update_filters(['short'], ['long']) + ['long'] + """ + filters = org + update + for x, y in [["lower", "upper"], ["long", "short"]]: + if x in org and y in update: + del filters[filters.index(x)] + if y in org and x in update: + del filters[filters.index(y)] + + return filters diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 0de97083..31cd8ab2 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -2,11 +2,14 @@ from __future__ import annotations import ast +import re from dataclasses import dataclass from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING +from mkapi import config + if TYPE_CHECKING: from collections.abc import Iterator @@ -32,6 +35,15 @@ def is_package(self) -> bool: def update(self) -> None: """Update contents.""" + def __iter__(self) -> Iterator[Module]: + yield self + if self.is_package(): + for module in iter_submodules(self): # TODO @D: cache + yield from module + + +cache: dict[str, Module] = {} + def get_module(name: str) -> Module: """Return a [Module] instance by name.""" @@ -39,13 +51,28 @@ def get_module(name: str) -> Module: if not spec or not spec.origin: raise ModuleNotFoundError path = Path(spec.origin) + mtime = path.stat().st_mtime + if name in cache and mtime == cache[name].mtime: + return cache[name] if not path.exists(): raise ModuleNotFoundError with path.open(encoding="utf-8") as f: source = f.read() node = ast.parse(source) - mtime = path.stat().st_mtime - return Module(name, path, source, mtime, node) + cache[name] = Module(name, path, source, mtime, node) + return cache[name] + + +def _is_module(path: Path) -> bool: + path_str = path.as_posix() + for pattern in config.exclude: + if re.search(pattern, path_str): + return False + if path.is_dir() and "__init__.py" in [p.name for p in path.iterdir()]: + return True + if path.is_file() and not path.stem.startswith("__") and path.suffix == ".py": + return True + return False def iter_submodules(module: Module) -> Iterator[Module]: @@ -55,7 +82,7 @@ def iter_submodules(module: Module) -> Iterator[Module]: return for location in spec.submodule_search_locations: for path in Path(location).iterdir(): - if path.suffix == ".py": + if _is_module(path): name = f"{module.name}.{path.stem}" yield get_module(name) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py new file mode 100644 index 00000000..717e4e7e --- /dev/null +++ b/src/mkapi/objects.py @@ -0,0 +1,32 @@ +"""Object class.""" +from __future__ import annotations + +import ast +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeAlias + +from mkapi.ast import iter_def_nodes + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from mkapi.modules import Module + + +Node: TypeAlias = ast.ClassDef | ast.Module | ast.FunctionDef + + +@dataclass +class Object: + """Object class.""" + + name: str + path: Path + source: str + module: Module + node: Node + + +def iter_objects(module: Module) -> Iterator: + pass diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py new file mode 100644 index 00000000..6223fafc --- /dev/null +++ b/src/mkapi/plugins.py @@ -0,0 +1,315 @@ +"""MkAPI Plugin class. + +MkAPI Plugin is a MkDocs plugin that creates Python API documentation +from Docstring. +""" +from __future__ import annotations + +import atexit +import inspect +import logging +import os +import re +import shutil +import sys +from collections.abc import Callable +from pathlib import Path +from typing import TypeGuard + +import yaml +from mkdocs.config import config_options +from mkdocs.config.base import Config +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.livereload import LiveReloadServer +from mkdocs.plugins import BasePlugin +from mkdocs.structure.files import Files, get_files +from mkdocs.structure.nav import Navigation +from mkdocs.structure.pages import Page as MkDocsPage +from mkdocs.structure.toc import AnchorLink, TableOfContents +from mkdocs.utils.templates import TemplateContext + +# import mkapi +from mkapi.filter import split_filters, update_filters +from mkapi.modules import Module, get_module + +# from mkapi.core.module import Module, get_module +# from mkapi.core.object import get_object +# from mkapi.core.page import Page as MkAPIPage + +logger = logging.getLogger("mkdocs") +global_config = {} + + +class MkAPIConfig(Config): + """Specify the config schema.""" + + src_dirs = config_options.Type(list, default=[]) + on_config = config_options.Type(str, default="") + filters = config_options.Type(list, default=[]) + callback = config_options.Type(str, default="") + abs_api_paths = config_options.Type(list, default=[]) + pages = config_options.Type(dict, default={}) + + +class MkAPIPlugin(BasePlugin[MkAPIConfig]): + """MkAPIPlugin class for API generation.""" + + server = None + + def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002 + """Insert `src_dirs` to `sys.path`.""" + _insert_sys_path(self.config) + _update_config(config, self) + if "admonition" not in config.markdown_extensions: + config.markdown_extensions.append("admonition") + return _on_config_plugin(config, self) + + +# def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 +# """Collect plugin CSS/JavaScript and appends them to `files`.""" +# root = Path(mkapi.__file__).parent / "theme" +# docs_dir = config.docs_dir +# config.docs_dir = root.as_posix() +# theme_files = get_files(config) +# config.docs_dir = docs_dir +# theme_name = config.theme.name or "mkdocs" + +# css = [] +# js = [] +# for file in theme_files: +# path = Path(file.src_path).as_posix() +# if path.endswith(".css"): +# if "common" in path or theme_name in path: +# files.append(file) +# css.append(path) +# elif path.endswith(".js"): +# files.append(file) +# js.append(path) +# elif path.endswith(".yml"): +# with (root / path).open() as f: +# data = yaml.safe_load(f) +# css = data.get("extra_css", []) + css +# js = data.get("extra_javascript", []) + js +# css = [x for x in css if x not in config.extra_css] +# js = [x for x in js if x not in config.extra_javascript] +# config.extra_css.extend(css) +# config.extra_javascript.extend(js) + +# return files + +# def on_page_markdown( +# self, +# markdown: str, +# page: MkDocsPage, +# config: MkDocsConfig, # noqa: ARG002 +# files: Files, # noqa: ARG002 +# **kwargs, # noqa: ARG002 +# ) -> str: +# """Convert Markdown source to intermidiate version.""" +# abs_src_path = page.file.abs_src_path +# clean_page_title(page) +# abs_api_paths = self.config.abs_api_paths +# filters = self.config.filters +# mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) +# self.config.pages[abs_src_path] = mkapi_page +# return mkapi_page.markdown + +# def on_page_content( +# self, +# html: str, +# page: MkDocsPage, +# config: MkDocsConfig, # noqa: ARG002 +# files: Files, # noqa: ARG002 +# **kwargs, # noqa: ARG002 +# ) -> str: +# """Merge HTML and MkAPI's node structure.""" +# if page.title: +# page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 +# abs_src_path = page.file.abs_src_path +# mkapi_page: MkAPIPage = self.config.pages[abs_src_path] +# return mkapi_page.content(html) + +# def on_page_context( +# self, +# context: TemplateContext, +# page: MkDocsPage, +# config: MkDocsConfig, # noqa: ARG002 +# nav: Navigation, # noqa: ARG002 +# **kwargs, # noqa: ARG002 +# ) -> TemplateContext: +# """Clear prefix in toc.""" +# abs_src_path = page.file.abs_src_path +# if abs_src_path in self.config.abs_api_paths: +# clear_prefix(page.toc, 2) +# else: +# mkapi_page: MkAPIPage = self.config.pages[abs_src_path] +# for level, id_ in mkapi_page.headings: +# clear_prefix(page.toc, level, id_) +# return context + +# def on_serve( # noqa: D102 +# self, +# server: LiveReloadServer, +# config: MkDocsConfig, # noqa: ARG002 +# builder: Callable, +# **kwargs, # noqa: ARG002 +# ) -> LiveReloadServer: +# for path in ["theme", "templates"]: +# path_str = (Path(mkapi.__file__).parent / path).as_posix() +# server.watch(path_str, builder) +# self.__class__.server = server +# return server + + +def _insert_sys_path(config: MkAPIConfig) -> None: + config_dir = Path(config.config_file_path).parent + for src_dir in config.src_dirs: + if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: + sys.path.insert(0, path) + if not config.src_dirs and (path := Path.cwd()) not in sys.path: + sys.path.insert(0, str(path)) + + +API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") + + +def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: + return isinstance(item, str) and re.match(API_URL_PATTERN, item) is not None + + +def _walk_nav(nav: list | dict, create_api_nav: Callable[[str], list]) -> None: + it = enumerate(nav) if isinstance(nav, list) else nav.items() + for k, item in it: + if _is_api_entry(item): + api_nav = create_api_nav(item) + nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} + elif isinstance(item, list | dict): + _walk_nav(item, create_api_nav) + + +def _collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[Path]]: + """Collect modules.""" + if not (m := re.match(API_URL_PATTERN, item)): + raise NotImplementedError + api_path, module_name = m.groups() + abs_api_path = Path(docs_dir) / api_path + Path.mkdir(abs_api_path, parents=True, exist_ok=True) + module_name, filters_ = split_filters(module_name) + filters = update_filters(filters, filters_) + + # def add_module(module: Module, package: str | None) -> None: + # module_path = module.object.id + ".md" + # abs_module_path = abs_api_path / module_path + # abs_api_paths.append(abs_module_path) + # create_page(abs_module_path, module, filters) + # module_name = module.object.id + # if package and "short_nav" in filters and module_name != package: + # module_name = module_name[len(package) + 1 :] + # modules[module_name] = (Path(api_path) / module_path).as_posix() + # abs_source_path = abs_api_path / "source" / module_path + # create_source_page(abs_source_path, module, filters) + + abs_api_paths: list[Path] = [] + modules: dict[str, str] = {} + nav, package = [], None + module = get_module(module_name) + print(module) + assert 0 + # for module in get_module(package_path): + # if module.object.kind == "package": + # if package and modules: + # nav.append({package: modules}) + # package = module.object.id + # modules.clear() + # if module.docstring or any(m.docstring for m in module.members): + # add_module(module, package) + # else: + # add_module(module, package) + # if package and modules: + # nav.append({package: modules}) + + return nav, abs_api_paths + + +def _update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: + if not isinstance(config.nav, list): + return [] + + def create_api_nav(item: str) -> list: + nav, paths = _collect(item, config.docs_dir, filters) + abs_api_paths.extend(paths) + return nav + + abs_api_paths: list[Path] = [] + _walk_nav(config.nav, create_api_nav) + + return abs_api_paths + + +def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + if not plugin.server: + plugin.config.abs_api_paths = _update_nav(config, plugin.config.filters) + global_config["nav"] = config.nav + global_config["abs_api_paths"] = plugin.config.abs_api_paths + else: + config.nav = global_config["nav"] + plugin.config.abs_api_paths = global_config["abs_api_paths"] + + +def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: + # if plugin.config.on_config: + # on_config = get_object(plugin.config.on_config) + # kwargs, params = {}, inspect.signature(on_config).parameters + # if "config" in params: + # kwargs["config"] = config + # if "plugin" in params: + # kwargs["plugin"] = plugin + # msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" + # logger.info(msg) + # config_ = on_config(**kwargs) + # if isinstance(config_, MkDocsConfig): + # return config_ + return config + + +# def create_page(path: Path, module: Module, filters: list[str]) -> None: +# """Create a page.""" +# with path.open("w") as f: +# f.write(module.get_markdown(filters)) + + +# def create_source_page(path: Path, module: Module, filters: list[str]) -> None: +# """Create a page for source.""" +# filters_str = "|".join(filters) +# with path.open("w") as f: +# f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") + + +# def clear_prefix( +# toc: TableOfContents | list[AnchorLink], +# level: int, +# id_: str = "", +# ) -> None: +# """Clear prefix.""" +# for toc_item in toc: +# if toc_item.level >= level and (not id_ or toc_item.title == id_): +# toc_item.title = toc_item.title.split(".")[-1] +# clear_prefix(toc_item.children, level) + + +# def clean_page_title(page: MkDocsPage) -> None: +# """Clean page title.""" +# title = str(page.title) +# if title.startswith("![mkapi]("): +# page.title = title[9:-1].split("|")[0] # type: ignore # noqa: PGH003 + + +# def _rmtree(path: Path) -> None: +# """Delete directory created by MkAPI.""" +# if not path.exists(): +# return +# try: +# shutil.rmtree(path) +# except PermissionError: +# msg = f"[MkAPI] Couldn't delete directory: {path}" +# logger.warning(msg) diff --git a/tests/test_modules.py b/tests/test_modules.py index 567fa2d9..ba5b83c3 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,8 +1,15 @@ -from mkapi.modules import find_submodules, get_module +import pytest +from mkapi import config +from mkapi.modules import Module, cache, find_submodules, get_module -def test_get_module(): - module = get_module("mkdocs") + +@pytest.fixture(scope="module") +def module(): + return get_module("mkdocs") + + +def test_get_module(module: Module): assert module.is_package() assert len(find_submodules(module)) > 0 module = get_module("mkdocs.structure.files") @@ -10,3 +17,17 @@ def test_get_module(): assert not find_submodules(module) assert module.mtime > 1703851000 assert module.path.stem == "files" + + +def test_iter_submodules(module: Module): + exclude = config.exclude.copy() + assert len(list(module)) < 45 + config.exclude.clear() + assert len(list(module)) > 45 + config.exclude = exclude + + +def test_cache(module: Module): + assert "mkdocs" in cache + id_ = id(module) + assert id(get_module("mkdocs")) == id_ diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 00000000..9e126c0d --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,82 @@ +from pathlib import Path + +import pytest +from jinja2.environment import Environment +from mkdocs.commands.build import build +from mkdocs.config import load_config +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import PluginCollection +from mkdocs.theme import Theme + +from mkapi.plugins import MkAPIConfig, MkAPIPlugin + + +@pytest.fixture(scope="module") +def config_file(): + return Path(__file__).parent.parent / "examples" / "mkdocs.yml" + + +def test_config_file_exists(config_file: Path): + assert config_file.exists() + + +@pytest.fixture(scope="module") +def mkdocs_config(config_file: Path): + return load_config(str(config_file)) + + +def test_mkdocs_config(mkdocs_config: MkDocsConfig): + config = mkdocs_config + assert isinstance(config, MkDocsConfig) + path = Path(config.config_file_path) + assert path.as_posix().endswith("mkapi/examples/mkdocs.yml") + assert config.site_name == "Doc for CI" + assert Path(config.docs_dir) == path.parent / "docs" + assert Path(config.site_dir) == path.parent / "site" + assert config.nav[0] == "index.md" # type: ignore + assert isinstance(config.plugins, PluginCollection) + assert isinstance(config.plugins["mkapi"], MkAPIPlugin) + assert config.pages is None + assert isinstance(config.theme, Theme) + assert config.theme.name == "mkdocs" + assert isinstance(config.theme.get_env(), Environment) + assert config.extra_css == ["custom.css"] + assert str(config.extra_javascript[0]).endswith("tex-mml-chtml.js") + assert "pymdownx.arithmatex" in config.markdown_extensions + + +@pytest.fixture(scope="module") +def mkapi_plugin(mkdocs_config: MkDocsConfig): + return mkdocs_config.plugins["mkapi"] + + +def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): + assert mkapi_plugin.server is None + assert isinstance(mkapi_plugin.config, MkAPIConfig) + + +@pytest.fixture(scope="module") +def mkapi_config(mkapi_plugin: MkAPIPlugin): + return mkapi_plugin.config + + +def test_mkapi_config(mkapi_config: MkAPIConfig): + config = mkapi_config + x = ["src_dirs", "on_config", "filters", "callback", "abs_api_paths", "pages"] + assert list(config) == x + assert config.src_dirs == ["."] + assert config.on_config == "custom.on_config" + + +# @pytest.fixture(scope="module") +# def env(mkdocs_config: MkDocsConfig): +# return mkdocs_config.theme.get_env() + + +def test_mkdocs_build(mkdocs_config: MkDocsConfig): + config = mkdocs_config + config.plugins.on_startup(command="build", dirty=False) + try: + build(config, dirty=False) + finally: + config.plugins.on_shutdown() From eec51a198b78403d3ecd6956a9170302e76a584b Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 31 Dec 2023 15:53:10 +0900 Subject: [PATCH 036/148] Module.get_tree --- .gitignore | 1 + examples/mkdocs.yml | 4 +- src/mkapi/config.py | 3 +- src/mkapi/filter.py | 1 - src/mkapi/modules.py | 22 +++++++-- src/mkapi/plugins.py | 111 ++++++++++++++++++++++-------------------- tests/test_modules.py | 15 +++++- 7 files changed, 94 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index 3a5297ad..f1c13f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,6 @@ ENV/ # MkDocs documentation site*/ +docs/api lcov.info \ No newline at end of file diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml index d975acdc..f6d2ee13 100644 --- a/examples/mkdocs.yml +++ b/examples/mkdocs.yml @@ -17,9 +17,9 @@ nav: - index.md - Examples: - example.md - - /mkapi.plugins|upper + - /mkdocs - ABC: /mkdocs.structure - - API: /mkdocs.structure.files|upper|strict + - API: /mkdocs - /mkapi extra_css: - custom.css diff --git a/src/mkapi/config.py b/src/mkapi/config.py index 6dbaca7a..6947a546 100644 --- a/src/mkapi/config.py +++ b/src/mkapi/config.py @@ -1,3 +1,4 @@ """Configuration file.""" -exclude = ["/tests"] +exclude: list[str] = ["/tests"] +filters: list[str] = [] diff --git a/src/mkapi/filter.py b/src/mkapi/filter.py index db115974..3e8b4a33 100644 --- a/src/mkapi/filter.py +++ b/src/mkapi/filter.py @@ -40,5 +40,4 @@ def update_filters(org: list[str], update: list[str]) -> list[str]: del filters[filters.index(x)] if y in org and x in update: del filters[filters.index(y)] - return filters diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 31cd8ab2..c9c74b13 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -38,9 +38,23 @@ def update(self) -> None: def __iter__(self) -> Iterator[Module]: yield self if self.is_package(): - for module in iter_submodules(self): # TODO @D: cache + for module in iter_submodules(self): yield from module + def get_tree(self) -> tuple[Module, list]: + """Return the package tree structure.""" + modules: list[Module | tuple[Module, list]] = [] + for module in find_submodules(self): + if module.is_package(): + modules.append(module.get_tree()) + else: + modules.append(module) + return (self, modules) + + def get_markdown(self, filters: list[str] | None) -> str: + """Return the markdown text of the module.""" + return "# test\n" + cache: dict[str, Module] = {} @@ -68,7 +82,8 @@ def _is_module(path: Path) -> bool: for pattern in config.exclude: if re.search(pattern, path_str): return False - if path.is_dir() and "__init__.py" in [p.name for p in path.iterdir()]: + it = (p.name for p in path.iterdir()) + if path.is_dir() and "__init__.py" in it: return True if path.is_file() and not path.stem.startswith("__") and path.suffix == ".py": return True @@ -89,4 +104,5 @@ def iter_submodules(module: Module) -> Iterator[Module]: def find_submodules(module: Module) -> list[Module]: """Return a list of submodules.""" - return list(iter_submodules(module)) + modules = iter_submodules(module) + return sorted(modules, key=lambda module: not module.is_package()) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 6223fafc..12d6921d 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -28,7 +28,7 @@ from mkdocs.structure.toc import AnchorLink, TableOfContents from mkdocs.utils.templates import TemplateContext -# import mkapi +import mkapi.config from mkapi.filter import split_filters, update_filters from mkapi.modules import Module, get_module @@ -170,6 +170,31 @@ def _insert_sys_path(config: MkAPIConfig) -> None: sys.path.insert(0, str(path)) +def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + if not plugin.server: + plugin.config.abs_api_paths = _update_nav(config, plugin.config.filters) + global_config["nav"] = config.nav + global_config["abs_api_paths"] = plugin.config.abs_api_paths + else: + config.nav = global_config["nav"] + plugin.config.abs_api_paths = global_config["abs_api_paths"] + + +def _update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: + if not isinstance(config.nav, list): + return [] + + def create_api_nav(item: str) -> list: + nav, paths = _collect(item, config.docs_dir, filters) + abs_api_paths.extend(paths) + return nav + + abs_api_paths: list[Path] = [] + _walk_nav(config.nav, create_api_nav) + + return abs_api_paths + + API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") @@ -197,65 +222,43 @@ def _collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[P module_name, filters_ = split_filters(module_name) filters = update_filters(filters, filters_) - # def add_module(module: Module, package: str | None) -> None: - # module_path = module.object.id + ".md" - # abs_module_path = abs_api_path / module_path - # abs_api_paths.append(abs_module_path) - # create_page(abs_module_path, module, filters) - # module_name = module.object.id - # if package and "short_nav" in filters and module_name != package: - # module_name = module_name[len(package) + 1 :] - # modules[module_name] = (Path(api_path) / module_path).as_posix() - # abs_source_path = abs_api_path / "source" / module_path - # create_source_page(abs_source_path, module, filters) + def add_module(module: Module, package: str | None) -> None: + module_path = module.name + ".md" + abs_module_path = abs_api_path / module_path + abs_api_paths.append(abs_module_path) + _create_page(abs_module_path, module, filters) + module_name = module.name + if package and "short_nav" in filters and module_name != package: + module_name = module_name[len(package) + 1 :] + modules[module_name] = (Path(api_path) / module_path).as_posix() + # abs_source_path = abs_api_path / "source" / module_path + # create_source_page(abs_source_path, module, filters) abs_api_paths: list[Path] = [] modules: dict[str, str] = {} nav, package = [], None - module = get_module(module_name) - print(module) - assert 0 - # for module in get_module(package_path): - # if module.object.kind == "package": - # if package and modules: - # nav.append({package: modules}) - # package = module.object.id - # modules.clear() - # if module.docstring or any(m.docstring for m in module.members): - # add_module(module, package) - # else: - # add_module(module, package) + # module = get_module(module_name) + + # if not module.is_package(): + # pass # TODO + + for module in get_module(module_name): + if module.is_package(): + if package and modules: + nav.append({package: modules}) + package = module.name + modules = {} + add_module(module, package) # Skip if no docstring. + else: + add_module(module, package) # if package and modules: # nav.append({package: modules}) + if modules: + nav.append({package or module.name: modules}) return nav, abs_api_paths -def _update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: - if not isinstance(config.nav, list): - return [] - - def create_api_nav(item: str) -> list: - nav, paths = _collect(item, config.docs_dir, filters) - abs_api_paths.extend(paths) - return nav - - abs_api_paths: list[Path] = [] - _walk_nav(config.nav, create_api_nav) - - return abs_api_paths - - -def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: - if not plugin.server: - plugin.config.abs_api_paths = _update_nav(config, plugin.config.filters) - global_config["nav"] = config.nav - global_config["abs_api_paths"] = plugin.config.abs_api_paths - else: - config.nav = global_config["nav"] - plugin.config.abs_api_paths = global_config["abs_api_paths"] - - def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: # if plugin.config.on_config: # on_config = get_object(plugin.config.on_config) @@ -272,10 +275,10 @@ def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig return config -# def create_page(path: Path, module: Module, filters: list[str]) -> None: -# """Create a page.""" -# with path.open("w") as f: -# f.write(module.get_markdown(filters)) +def _create_page(path: Path, module: Module, filters: list[str]) -> None: + """Create a page.""" + with path.open("w") as f: + f.write(module.get_markdown(filters)) # def create_source_page(path: Path, module: Module, filters: list[str]) -> None: diff --git a/tests/test_modules.py b/tests/test_modules.py index ba5b83c3..5eb16a01 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -27,7 +27,18 @@ def test_iter_submodules(module: Module): config.exclude = exclude +def test_get_tree(): + module = get_module("mkdocs.structure.files") + tree = module.get_tree() + assert isinstance(tree[0], Module) + assert tree[1] == [] + module = get_module("mkdocs.structure") + tree = module.get_tree() + assert isinstance(tree[0], Module) + assert len(tree[1]) + assert isinstance(tree[1][0], Module) + + def test_cache(module: Module): assert "mkdocs" in cache - id_ = id(module) - assert id(get_module("mkdocs")) == id_ + assert id(get_module("mkdocs")) == id(module) From 518424e1750e70804a280c02ffc38bab3e712bc6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 11:16:03 +0900 Subject: [PATCH 037/148] get_arguments --- examples/docs/api/mkdocs.structure.md | 3 + examples/docs/example.md | 7 + examples/mkdocs.yml | 12 +- src/mkapi/ast.py | 109 +++++----- src/mkapi/inspect.py | 11 ++ src/mkapi/modules.py | 28 ++- src/mkapi/parser.py | 59 ++++++ src/mkapi/plugins.py | 251 ++++++++++++------------ tests/ast/test_arguments.py | 52 +++++ tests/{test_ast.py => ast/test_node.py} | 60 +++--- tests/stdlib/__init__.py | 0 tests/{ast => stdlib}/test_class.py | 0 tests/{ast => stdlib}/test_function.py | 0 tests/test_inspect.py | 13 ++ tests/test_modules.py | 4 +- tests/test_parser.py | 2 + 16 files changed, 392 insertions(+), 219 deletions(-) create mode 100644 examples/docs/api/mkdocs.structure.md create mode 100644 src/mkapi/inspect.py create mode 100644 src/mkapi/parser.py create mode 100644 tests/ast/test_arguments.py rename tests/{test_ast.py => ast/test_node.py} (88%) create mode 100644 tests/stdlib/__init__.py rename tests/{ast => stdlib}/test_class.py (100%) rename tests/{ast => stdlib}/test_function.py (100%) create mode 100644 tests/test_inspect.py create mode 100644 tests/test_parser.py diff --git a/examples/docs/api/mkdocs.structure.md b/examples/docs/api/mkdocs.structure.md new file mode 100644 index 00000000..fb18b16a --- /dev/null +++ b/examples/docs/api/mkdocs.structure.md @@ -0,0 +1,3 @@ +# mkdocs.structure + +## mkdocs.structure diff --git a/examples/docs/example.md b/examples/docs/example.md index e69de29b..7ec77c65 100644 --- a/examples/docs/example.md +++ b/examples/docs/example.md @@ -0,0 +1,7 @@ +# Example 123 + +## section 1 + +## section 2 + +## section 3 diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml index f6d2ee13..6e146ef7 100644 --- a/examples/mkdocs.yml +++ b/examples/mkdocs.yml @@ -15,12 +15,12 @@ plugins: on_config: custom.on_config nav: - index.md - - Examples: - - example.md - - /mkdocs - - ABC: /mkdocs.structure - - API: /mkdocs - - /mkapi + - /mkdocs.structure + - example.md + # - D: example.md + # - E: example.md + + extra_css: - custom.css extra_javascript: diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 5f7ef0f8..e9a6ba7d 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -6,27 +6,26 @@ AnnAssign, Assign, AsyncFunctionDef, - Attribute, ClassDef, Constant, Expr, FunctionDef, Import, ImportFrom, - List, Module, Name, - Subscript, - Tuple, ) -from inspect import cleandoc -from typing import TYPE_CHECKING, Any, TypeAlias, TypeGuard +from dataclasses import dataclass +from inspect import Parameter, cleandoc +from typing import TYPE_CHECKING, TypeAlias, TypeGuard if TYPE_CHECKING: from ast import AST - from collections.abc import Callable, Iterator, Sequence + from collections.abc import Iterator, Sequence + from inspect import _ParameterKind Import_: TypeAlias = Import | ImportFrom +FuncDef: TypeAlias = AsyncFunctionDef | FunctionDef Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef Assign_: TypeAlias = Assign | AnnAssign Doc: TypeAlias = Module | Def | Assign_ @@ -152,17 +151,28 @@ def get_def_names(node: Module | ClassDef) -> list[str]: return list(iter_def_names(node)) +def iter_nodes(node: Module | ClassDef) -> Iterator[Def | Assign_]: + """Yield nodes.""" + yield from iter_assign_nodes(node) + yield from iter_def_nodes(node) + + +def get_nodes(node: Module | ClassDef) -> list[Def | Assign_]: + """Return a list of nodes.""" + return list(iter_nodes(node)) + + def iter_names(node: Module | ClassDef) -> Iterator[tuple[str, str]]: - """Yield import and def names.""" + """Yield names as (name, fullname) pairs.""" yield from iter_import_names(node) - for name in iter_def_names(node): - yield name, f".{name}" for name, _ in iter_assign_names(node): yield name, f".{name}" + for name in iter_def_names(node): + yield name, f".{name}" def get_names(node: Module | ClassDef) -> dict[str, str]: - """Return a dictionary of import and def names.""" + """Return a dictionary of names as (name => fullname).""" return dict(iter_names(node)) @@ -176,50 +186,55 @@ def get_docstring(node: Doc) -> str | None: raise TypeError(msg) -def parse_attribute(node: Attribute) -> str: # noqa: D103 - return ".".join([parse_node(node.value), node.attr]) - +ARGS_KIND: dict[_ParameterKind, str] = { + Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list + Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list + Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None + Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list + Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None +} -def parse_subscript(node: Subscript) -> str: # noqa: D103 - value = parse_node(node.value) - slice_ = parse_node(node.slice) - return f"{value}[{slice_}]" +def iter_arguments(node: FuncDef) -> Iterator[tuple[ast.arg, _ParameterKind]]: + """Yield arguments from the function node.""" + for kind, attr in ARGS_KIND.items(): + if args := getattr(node.args, attr): + it = args if isinstance(args, list) else [args] + yield from ((arg, kind) for arg in it) -def parse_constant(node: Constant) -> str: # noqa: D103 - if node.value is Ellipsis: - return "..." - if isinstance(node.value, str): - return node.value - return parse_value(node.value) +def iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: + """Yield defaults from the function node.""" + args = node.args + num_positional = len(args.posonlyargs) + len(args.args) + nones = [None] * num_positional + yield from [*nones, *args.defaults][-num_positional:] + yield from args.kw_defaults -def parse_list(node: List) -> str: # noqa: D103 - return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" +@dataclass +class _Argument: + annotation: ast.expr | None + default: ast.expr | None + kind: _ParameterKind -def parse_tuple(node: Tuple) -> str: # noqa: D103 - return ", ".join(parse_node(n) for n in node.elts) +@dataclass +class _Arguments: + _args: dict[str, _Argument] -def parse_value(value: Any) -> str: # noqa: D103, ANN401 - return str(value) + def __getattr__(self, name: str) -> _Argument: + return self._args[name] -PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ - (Attribute, parse_attribute), - (Subscript, parse_subscript), - (Constant, parse_constant), - (List, parse_list), - (Tuple, parse_tuple), - (Name, "id"), -] - - -def parse_node(node: AST) -> str: - """Return the string expression for an AST node.""" - for type_, parse in PARSE_NODE_FUNCTIONS: - if isinstance(node, type_): - node_str = parse(node) if callable(parse) else getattr(node, parse) - return node_str if isinstance(node_str, str) else str(node_str) - return ast.unparse(node) +def get_arguments(node: FuncDef) -> _Arguments: + """Return a dictionary of the function arguments.""" + it = iter_defaults(node) + args: dict[str, _Argument] = {} + for arg, kind in iter_arguments(node): + if kind in [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]: + default = None + else: + default = next(it) + args[arg.arg] = _Argument(arg.annotation, default, kind) + return _Arguments(args) diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py new file mode 100644 index 00000000..c287fd19 --- /dev/null +++ b/src/mkapi/inspect.py @@ -0,0 +1,11 @@ +"""Inspect module.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Ref: + """Reference of type.""" + + fullname: str diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index c9c74b13..274b75f0 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -6,13 +6,18 @@ from dataclasses import dataclass from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias +import mkapi.ast from mkapi import config if TYPE_CHECKING: from collections.abc import Iterator +Def: TypeAlias = ast.AsyncFunctionDef | ast.FunctionDef | ast.ClassDef +Assign_: TypeAlias = ast.Assign | ast.AnnAssign +Node: TypeAlias = Def | Assign_ + @dataclass class Module: @@ -35,11 +40,24 @@ def is_package(self) -> bool: def update(self) -> None: """Update contents.""" - def __iter__(self) -> Iterator[Module]: - yield self + def get_node(self, name: str) -> Node: + """Return a node by name.""" + nodes = mkapi.ast.get_nodes(self.node) + node = mkapi.ast.get_by_name(nodes, name) + if node is None: + raise NameError + return node + + def get_names(self) -> dict[str, str]: + """Return a dictionary of names as (name => fullname).""" + return dict(mkapi.ast.iter_names(self.node)) + + def iter_submodules(self) -> Iterator[Module]: + """Yield submodules.""" if self.is_package(): for module in iter_submodules(self): - yield from module + yield module + yield from module.iter_submodules() def get_tree(self) -> tuple[Module, list]: """Return the package tree structure.""" @@ -53,7 +71,7 @@ def get_tree(self) -> tuple[Module, list]: def get_markdown(self, filters: list[str] | None) -> str: """Return the markdown text of the module.""" - return "# test\n" + return f"# {self.name}\n\n## {self.name}\n" cache: dict[str, Module] = {} diff --git a/src/mkapi/parser.py b/src/mkapi/parser.py new file mode 100644 index 00000000..fa53b3e1 --- /dev/null +++ b/src/mkapi/parser.py @@ -0,0 +1,59 @@ +"""Parser module.""" +from __future__ import annotations + +import ast +from ast import Attribute, Constant, List, Name, Subscript, Tuple +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ast import AST + from collections.abc import Callable + + +def parse_attribute(node: Attribute) -> str: # noqa: D103 + return ".".join([parse_node(node.value), node.attr]) + + +def parse_subscript(node: Subscript) -> str: # noqa: D103 + value = parse_node(node.value) + slice_ = parse_node(node.slice) + return f"{value}[{slice_}]" + + +def parse_constant(node: Constant) -> str: # noqa: D103 + if node.value is Ellipsis: + return "..." + if isinstance(node.value, str): + return node.value + return parse_value(node.value) + + +def parse_list(node: List) -> str: # noqa: D103 + return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" + + +def parse_tuple(node: Tuple) -> str: # noqa: D103 + return ", ".join(parse_node(n) for n in node.elts) + + +def parse_value(value: Any) -> str: # noqa: D103, ANN401 + return str(value) + + +PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ + (Attribute, parse_attribute), + (Subscript, parse_subscript), + (Constant, parse_constant), + (List, parse_list), + (Tuple, parse_tuple), + (Name, "id"), +] + + +def parse_node(node: AST) -> str: + """Return the string expression for an AST node.""" + for type_, parse in PARSE_NODE_FUNCTIONS: + if isinstance(node, type_): + node_str = parse(node) if callable(parse) else getattr(node, parse) + return node_str if isinstance(node_str, str) else str(node_str) + return ast.unparse(node) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 12d6921d..88388d1d 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -64,101 +64,100 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: AR config.markdown_extensions.append("admonition") return _on_config_plugin(config, self) - -# def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 -# """Collect plugin CSS/JavaScript and appends them to `files`.""" -# root = Path(mkapi.__file__).parent / "theme" -# docs_dir = config.docs_dir -# config.docs_dir = root.as_posix() -# theme_files = get_files(config) -# config.docs_dir = docs_dir -# theme_name = config.theme.name or "mkdocs" - -# css = [] -# js = [] -# for file in theme_files: -# path = Path(file.src_path).as_posix() -# if path.endswith(".css"): -# if "common" in path or theme_name in path: -# files.append(file) -# css.append(path) -# elif path.endswith(".js"): -# files.append(file) -# js.append(path) -# elif path.endswith(".yml"): -# with (root / path).open() as f: -# data = yaml.safe_load(f) -# css = data.get("extra_css", []) + css -# js = data.get("extra_javascript", []) + js -# css = [x for x in css if x not in config.extra_css] -# js = [x for x in js if x not in config.extra_javascript] -# config.extra_css.extend(css) -# config.extra_javascript.extend(js) - -# return files - -# def on_page_markdown( -# self, -# markdown: str, -# page: MkDocsPage, -# config: MkDocsConfig, # noqa: ARG002 -# files: Files, # noqa: ARG002 -# **kwargs, # noqa: ARG002 -# ) -> str: -# """Convert Markdown source to intermidiate version.""" -# abs_src_path = page.file.abs_src_path -# clean_page_title(page) -# abs_api_paths = self.config.abs_api_paths -# filters = self.config.filters -# mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) -# self.config.pages[abs_src_path] = mkapi_page -# return mkapi_page.markdown - -# def on_page_content( -# self, -# html: str, -# page: MkDocsPage, -# config: MkDocsConfig, # noqa: ARG002 -# files: Files, # noqa: ARG002 -# **kwargs, # noqa: ARG002 -# ) -> str: -# """Merge HTML and MkAPI's node structure.""" -# if page.title: -# page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 -# abs_src_path = page.file.abs_src_path -# mkapi_page: MkAPIPage = self.config.pages[abs_src_path] -# return mkapi_page.content(html) - -# def on_page_context( -# self, -# context: TemplateContext, -# page: MkDocsPage, -# config: MkDocsConfig, # noqa: ARG002 -# nav: Navigation, # noqa: ARG002 -# **kwargs, # noqa: ARG002 -# ) -> TemplateContext: -# """Clear prefix in toc.""" -# abs_src_path = page.file.abs_src_path -# if abs_src_path in self.config.abs_api_paths: -# clear_prefix(page.toc, 2) -# else: -# mkapi_page: MkAPIPage = self.config.pages[abs_src_path] -# for level, id_ in mkapi_page.headings: -# clear_prefix(page.toc, level, id_) -# return context - -# def on_serve( # noqa: D102 -# self, -# server: LiveReloadServer, -# config: MkDocsConfig, # noqa: ARG002 -# builder: Callable, -# **kwargs, # noqa: ARG002 -# ) -> LiveReloadServer: -# for path in ["theme", "templates"]: -# path_str = (Path(mkapi.__file__).parent / path).as_posix() -# server.watch(path_str, builder) -# self.__class__.server = server -# return server + # def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 + # """Collect plugin CSS/JavaScript and appends them to `files`.""" + # root = Path(mkapi.__file__).parent / "theme" + # docs_dir = config.docs_dir + # config.docs_dir = root.as_posix() + # theme_files = get_files(config) + # config.docs_dir = docs_dir + # theme_name = config.theme.name or "mkdocs" + + # css = [] + # js = [] + # for file in theme_files: + # path = Path(file.src_path).as_posix() + # if path.endswith(".css"): + # if "common" in path or theme_name in path: + # files.append(file) + # css.append(path) + # elif path.endswith(".js"): + # files.append(file) + # js.append(path) + # elif path.endswith(".yml"): + # with (root / path).open() as f: + # data = yaml.safe_load(f) + # css = data.get("extra_css", []) + css + # js = data.get("extra_javascript", []) + js + # css = [x for x in css if x not in config.extra_css] + # js = [x for x in js if x not in config.extra_javascript] + # config.extra_css.extend(css) + # config.extra_javascript.extend(js) + + # return files + + # def on_page_markdown( + # self, + # markdown: str, + # page: MkDocsPage, + # config: MkDocsConfig, # noqa: ARG002 + # files: Files, # noqa: ARG002 + # **kwargs, # noqa: ARG002 + # ) -> str: + # """Convert Markdown source to intermidiate version.""" + # abs_src_path = page.file.abs_src_path + # clean_page_title(page) + # abs_api_paths = self.config.abs_api_paths + # filters = self.config.filters + # mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) + # self.config.pages[abs_src_path] = mkapi_page + # return mkapi_page.markdown + + # def on_page_content( + # self, + # html: str, + # page: MkDocsPage, + # config: MkDocsConfig, # noqa: ARG002 + # files: Files, # noqa: ARG002 + # **kwargs, # noqa: ARG002 + # ) -> str: + # """Merge HTML and MkAPI's node structure.""" + # if page.title: + # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 + # abs_src_path = page.file.abs_src_path + # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + # return mkapi_page.content(html) + + # def on_page_context( + # self, + # context: TemplateContext, + # page: MkDocsPage, + # config: MkDocsConfig, # noqa: ARG002 + # nav: Navigation, # noqa: ARG002 + # **kwargs, # noqa: ARG002 + # ) -> TemplateContext: + # """Clear prefix in toc.""" + # abs_src_path = page.file.abs_src_path + # if abs_src_path in self.config.abs_api_paths: + # clear_prefix(page.toc, 2) + # else: + # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + # for level, id_ in mkapi_page.headings: + # clear_prefix(page.toc, level, id_) + # return context + + def on_serve( # noqa: D102 + self, + server: LiveReloadServer, + config: MkDocsConfig, # noqa: ARG002 + builder: Callable, + **kwargs, # noqa: ARG002 + ) -> LiveReloadServer: + # for path in ["theme", "templates"]: + # path_str = (Path(mkapi.__file__).parent / path).as_posix() + # server.watch(path_str, builder) + self.__class__.server = server + return server def _insert_sys_path(config: MkAPIConfig) -> None: @@ -191,27 +190,32 @@ def create_api_nav(item: str) -> list: abs_api_paths: list[Path] = [] _walk_nav(config.nav, create_api_nav) - + print("AAAAAAAAAAAAAAAAAAA\n", config.nav) + abs_api_paths = list(set(abs_api_paths)) return abs_api_paths -API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") - - -def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: - return isinstance(item, str) and re.match(API_URL_PATTERN, item) is not None - - def _walk_nav(nav: list | dict, create_api_nav: Callable[[str], list]) -> None: + print("DDD", nav) it = enumerate(nav) if isinstance(nav, list) else nav.items() for k, item in it: if _is_api_entry(item): api_nav = create_api_nav(item) - nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} + print("EEE", k, api_nav, type(nav)) + # nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} + nav[k] = {"AAAA": [api_nav]} + print("FFF", nav) elif isinstance(item, list | dict): _walk_nav(item, create_api_nav) +API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") + + +def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: + return isinstance(item, str) and re.match(API_URL_PATTERN, item) is not None + + def _collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[Path]]: """Collect modules.""" if not (m := re.match(API_URL_PATTERN, item)): @@ -237,28 +241,37 @@ def add_module(module: Module, package: str | None) -> None: abs_api_paths: list[Path] = [] modules: dict[str, str] = {} nav, package = [], None - # module = get_module(module_name) + module = get_module(module_name) + print(module.get_tree()) + add_module(module, None) + nav = modules # if not module.is_package(): # pass # TODO - for module in get_module(module_name): - if module.is_package(): - if package and modules: - nav.append({package: modules}) - package = module.name - modules = {} - add_module(module, package) # Skip if no docstring. - else: - add_module(module, package) + # for module in get_module(module_name): + # if module.is_package(): + # if package and modules: + # nav.append({package: modules}) + # package = module.name + # modules = {} + # add_module(module, package) # Skip if no docstring. + # else: + # add_module(module, package) # if package and modules: # nav.append({package: modules}) - if modules: - nav.append({package or module.name: modules}) + # if modules: + # nav.append({package or module.name: modules}) return nav, abs_api_paths +def _create_page(path: Path, module: Module, filters: list[str]) -> None: + """Create a page.""" + with path.open("w") as f: + f.write(module.get_markdown(filters)) + + def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: # if plugin.config.on_config: # on_config = get_object(plugin.config.on_config) @@ -275,12 +288,6 @@ def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig return config -def _create_page(path: Path, module: Module, filters: list[str]) -> None: - """Create a page.""" - with path.open("w") as f: - f.write(module.get_markdown(filters)) - - # def create_source_page(path: Path, module: Module, filters: list[str]) -> None: # """Create a page for source.""" # filters_str = "|".join(filters) diff --git a/tests/ast/test_arguments.py b/tests/ast/test_arguments.py new file mode 100644 index 00000000..b631fddb --- /dev/null +++ b/tests/ast/test_arguments.py @@ -0,0 +1,52 @@ +import ast +from inspect import Parameter + +from mkapi.ast import get_arguments + + +def _get_args(source: str): + node = ast.parse(source).body[0] + assert isinstance(node, ast.FunctionDef) + return get_arguments(node) + + +def test_get_arguments_1(): + args = _get_args("def f():\n pass") + assert not args._args # noqa: SLF001 + args = _get_args("def f(x):\n pass") + assert args.x.annotation is None + assert args.x.default is None + assert args.x.kind is Parameter.POSITIONAL_OR_KEYWORD + x = _get_args("def f(x=1):\n pass").x + assert isinstance(x.default, ast.Constant) + x = _get_args("def f(x:str='s'):\n pass").x + assert isinstance(x.annotation, ast.Name) + assert x.annotation.id == "str" + assert isinstance(x.default, ast.Constant) + assert x.default.value == "s" + x = _get_args("def f(x:'X'='s'):\n pass").x + assert isinstance(x.annotation, ast.Constant) + assert x.annotation.value == "X" + + +def test_get_arguments_2(): + x = _get_args("def f(x:tuple[int]=(1,)):\n pass").x + assert isinstance(x.annotation, ast.Subscript) + assert isinstance(x.annotation.value, ast.Name) + assert x.annotation.value.id == "tuple" + assert isinstance(x.annotation.slice, ast.Name) + assert x.annotation.slice.id == "int" + assert isinstance(x.default, ast.Tuple) + assert isinstance(x.default.elts[0], ast.Constant) + assert x.default.elts[0].value == 1 + + +def test_get_arguments_3(): + x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass").x + assert isinstance(x.annotation, ast.Subscript) + assert isinstance(x.annotation.value, ast.Name) + assert x.annotation.value.id == "tuple" + assert isinstance(x.annotation.slice, ast.Tuple) + assert x.annotation.slice.elts[0].id == "int" # type: ignore + assert x.annotation.slice.elts[1].id == "str" # type: ignore + assert isinstance(x.default, ast.Tuple) diff --git a/tests/test_ast.py b/tests/ast/test_node.py similarity index 88% rename from tests/test_ast.py rename to tests/ast/test_node.py index 3a4f5088..c939b533 100644 --- a/tests/test_ast.py +++ b/tests/ast/test_node.py @@ -41,16 +41,6 @@ def test_get_import_names(module: Module): assert names["urlquote"] == "urllib.parse.quote" -@pytest.fixture(scope="module") -def def_nodes(module: Module): - return get_def_nodes(module.node) - - -def test_get_def_nodes(def_nodes): - assert any(node.name == "get_files" for node in def_nodes) - assert any(node.name == "Files" for node in def_nodes) - - def test_get_by_name(def_nodes): node = get_by_name(def_nodes, "get_files") assert isinstance(node, ast.FunctionDef) @@ -64,21 +54,14 @@ def test_get_by_name(def_nodes): assert get_name(node) == "EXCLUDED" -def test_get_docstring(def_nodes): - node = get_by_name(def_nodes, "get_files") - assert isinstance(node, ast.FunctionDef) - doc = get_docstring(node) - assert isinstance(doc, str) - assert doc.startswith("Walk the `docs_dir`") - with pytest.raises(TypeError): - get_docstring(node.args) # type: ignore - node = get_by_name(def_nodes, "InclusionLevel") - assert isinstance(node, ast.ClassDef) - nodes = get_assign_nodes(node) - node = get_by_name(nodes, "INCLUDED") - doc = get_docstring(node) # type: ignore - assert isinstance(doc, str) - assert doc.startswith("The file is part of the site.") +@pytest.fixture(scope="module") +def def_nodes(module: Module): + return get_def_nodes(module.node) + + +def test_get_def_nodes(def_nodes): + assert any(node.name == "get_files" for node in def_nodes) + assert any(node.name == "Files" for node in def_nodes) def test_get_assign_names(module: Module, def_nodes): @@ -105,15 +88,18 @@ def test_get_names(module: Module, def_nodes): assert names["url"] == ".url" -# def test_source(source): -# print(source) -# assert 0 - - -# def test_get_assign_nodes(def_nodes): -# for node in def_nodes: -# if node.name == "InclusionLevel": -# nodes = get_assign_nodes(node) -# for node -# print(len(nodes)) -# assert 0 +def test_get_docstring(def_nodes): + node = get_by_name(def_nodes, "get_files") + assert isinstance(node, ast.FunctionDef) + doc = get_docstring(node) + assert isinstance(doc, str) + assert doc.startswith("Walk the `docs_dir`") + with pytest.raises(TypeError): + get_docstring(node.args) # type: ignore + node = get_by_name(def_nodes, "InclusionLevel") + assert isinstance(node, ast.ClassDef) + nodes = get_assign_nodes(node) + node = get_by_name(nodes, "INCLUDED") + doc = get_docstring(node) # type: ignore + assert isinstance(doc, str) + assert doc.startswith("The file is part of the site.") diff --git a/tests/stdlib/__init__.py b/tests/stdlib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ast/test_class.py b/tests/stdlib/test_class.py similarity index 100% rename from tests/ast/test_class.py rename to tests/stdlib/test_class.py diff --git a/tests/ast/test_function.py b/tests/stdlib/test_function.py similarity index 100% rename from tests/ast/test_function.py rename to tests/stdlib/test_function.py diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 00000000..c9b9d0cf --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,13 @@ +import ast + +import pytest + +from mkapi.modules import get_module + + +def test_(): + module = get_module("mkdocs.commands.build") + names = module.get_names() + node = module.get_node("get_context") + print(ast.unparse(node)) + # assert 0 diff --git a/tests/test_modules.py b/tests/test_modules.py index 5eb16a01..20ca9b0c 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -21,9 +21,9 @@ def test_get_module(module: Module): def test_iter_submodules(module: Module): exclude = config.exclude.copy() - assert len(list(module)) < 45 + assert len(list(module.iter_submodules())) < 45 config.exclude.clear() - assert len(list(module)) > 45 + assert len(list(module.iter_submodules())) > 45 config.exclude = exclude diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 00000000..6edc9b86 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,2 @@ +def test_parser(): + pass From 089b8a219443eb1cccf53baedff1898700db159c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 11:37:52 +0900 Subject: [PATCH 038/148] ast -> ast.node --- src/mkapi/ast/__init__.py | 0 src/mkapi/{ast.py => ast/node.py} | 2 +- src/mkapi/modules.py | 8 +++--- src/mkapi/objects.py | 42 +++++++++++++++---------------- tests/ast/test_arguments.py | 2 +- tests/ast/test_node.py | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) create mode 100644 src/mkapi/ast/__init__.py rename src/mkapi/{ast.py => ast/node.py} (95%) diff --git a/src/mkapi/ast/__init__.py b/src/mkapi/ast/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mkapi/ast.py b/src/mkapi/ast/node.py similarity index 95% rename from src/mkapi/ast.py rename to src/mkapi/ast/node.py index e9a6ba7d..5c5cc6f1 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast/node.py @@ -119,7 +119,7 @@ def iter_assign_names(node: Module | ClassDef) -> Iterator[tuple[str, str | None """Yield assign node names.""" for child in iter_assign_nodes(node): if name := _get_assign_name(child): - fullname = child.value and ast.unparse(child.value) # TODO @D: fix unparse + fullname = child.value and ast.unparse(child.value) yield name, fullname diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 274b75f0..9aa1fcd6 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, TypeAlias -import mkapi.ast +import mkapi.ast.node from mkapi import config if TYPE_CHECKING: @@ -42,15 +42,15 @@ def update(self) -> None: def get_node(self, name: str) -> Node: """Return a node by name.""" - nodes = mkapi.ast.get_nodes(self.node) - node = mkapi.ast.get_by_name(nodes, name) + nodes = mkapi.ast.node.get_nodes(self.node) + node = mkapi.ast.node.get_by_name(nodes, name) if node is None: raise NameError return node def get_names(self) -> dict[str, str]: """Return a dictionary of names as (name => fullname).""" - return dict(mkapi.ast.iter_names(self.node)) + return dict(mkapi.ast.node.iter_names(self.node)) def iter_submodules(self) -> Iterator[Module]: """Yield submodules.""" diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 717e4e7e..3218584d 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -1,32 +1,32 @@ -"""Object class.""" -from __future__ import annotations +# """Object class.""" +# from __future__ import annotations -import ast -from dataclasses import dataclass -from typing import TYPE_CHECKING, TypeAlias +# import ast +# from dataclasses import dataclass +# from typing import TYPE_CHECKING, TypeAlias -from mkapi.ast import iter_def_nodes +# from mkapi.ast import iter_def_nodes -if TYPE_CHECKING: - from collections.abc import Iterator - from pathlib import Path +# if TYPE_CHECKING: +# from collections.abc import Iterator +# from pathlib import Path - from mkapi.modules import Module +# from mkapi.modules import Module -Node: TypeAlias = ast.ClassDef | ast.Module | ast.FunctionDef +# Node: TypeAlias = ast.ClassDef | ast.Module | ast.FunctionDef -@dataclass -class Object: - """Object class.""" +# @dataclass +# class Object: +# """Object class.""" - name: str - path: Path - source: str - module: Module - node: Node +# name: str +# path: Path +# source: str +# module: Module +# node: Node -def iter_objects(module: Module) -> Iterator: - pass +# def iter_objects(module: Module) -> Iterator: +# pass diff --git a/tests/ast/test_arguments.py b/tests/ast/test_arguments.py index b631fddb..4afbf47a 100644 --- a/tests/ast/test_arguments.py +++ b/tests/ast/test_arguments.py @@ -1,7 +1,7 @@ import ast from inspect import Parameter -from mkapi.ast import get_arguments +from mkapi.ast.node import get_arguments def _get_args(source: str): diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index c939b533..1ef4f11f 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -2,7 +2,7 @@ import pytest -from mkapi.ast import ( +from mkapi.ast.node import ( get_assign_names, get_assign_nodes, get_by_name, From 105ccaecad5b5f7f8bcf6d369e31fd7d5898426f Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 20:13:25 +0900 Subject: [PATCH 039/148] expr --- src/mkapi/ast/expr.py | 101 ++++++++++++++++++++ src/mkapi/ast/node.py | 147 +++++++++++++++++++++++----- src/mkapi/modules.py | 200 +++++++++++++++------------------------ src/mkapi/objects.py | 2 +- src/mkapi/parser.py | 59 ------------ src/mkapi/plugins.py | 3 +- src/mkapi/utils.py | 67 +++++++++++++ tests/ast/test_assign.py | 27 ++++++ tests/ast/test_expr.py | 64 +++++++++++++ tests/ast/test_node.py | 19 ++-- tests/test_inspect.py | 17 ++-- tests/test_modules.py | 44 --------- tests/test_plugins.py | 14 +-- tests/test_utils.py | 20 ++++ 14 files changed, 506 insertions(+), 278 deletions(-) create mode 100644 src/mkapi/ast/expr.py delete mode 100644 src/mkapi/parser.py create mode 100644 src/mkapi/utils.py create mode 100644 tests/ast/test_assign.py create mode 100644 tests/ast/test_expr.py create mode 100644 tests/test_utils.py diff --git a/src/mkapi/ast/expr.py b/src/mkapi/ast/expr.py new file mode 100644 index 00000000..9ab7f9e9 --- /dev/null +++ b/src/mkapi/ast/expr.py @@ -0,0 +1,101 @@ +"""Parse expr node.""" +from __future__ import annotations + +import ast +from ast import Attribute, Call, Constant, List, Name, Slice, Starred, Subscript, Tuple +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import TypeAlias + +Callback: TypeAlias = Callable[[ast.expr], str | ast.expr | None] | None +# type Callback = Callable[[ast.expr], str | ast.expr] # Python 3.12 + + +def _parse_attribute(node: Attribute, callback: Callback) -> str: + return ".".join([parse_expr(node.value, callback), node.attr]) + + +def _parse_subscript(node: Subscript, callback: Callback) -> str: + value = parse_expr(node.value, callback) + if isinstance(node.slice, Slice): + slice_ = _parse_slice(node.slice, callback) + elif isinstance(node.slice, Tuple): + slice_ = _parse_elts(node.slice.elts, callback) + else: + slice_ = parse_expr(node.slice, callback) + return f"{value}[{slice_}]" + + +def _parse_slice(node: Slice, callback: Callback) -> str: + lower = parse_expr(node.lower, callback) if node.lower else "" + upper = parse_expr(node.upper, callback) if node.upper else "" + step = ":" + parse_expr(node.step, callback) if node.step else "" + return f"{lower}:{upper}{step}" + + +def _parse_starred(node: Starred, callback: Callback) -> str: + return "*" + parse_expr(node.value, callback) + + +def _parse_call(node: Call, callback: Callback) -> str: + func = parse_expr(node.func, callback) + args = ", ".join(parse_expr(arg, callback) for arg in node.args) + it = [_parse_keyword(keyword, callback) for keyword in node.keywords] + keywords = (", " + ", ".join(it)) if it else "" + return f"{func}({args}{keywords})" + + +def _parse_keyword(node: ast.keyword, callback: Callback) -> str: + value = parse_expr(node.value, callback) + return f"**{value}" if node.arg is None else f"{node.arg}={value}" + + +def _parse_elts(nodes: list[ast.expr], callback: Callback) -> str: + return ", ".join(parse_expr(node, callback) for node in nodes) + + +def _parse_list(node: List, callback: Callback) -> str: + return f"[{_parse_elts(node.elts,callback)}]" + + +def _parse_tuple(node: Tuple, callback: Callback) -> str: + elts = _parse_elts(node.elts, callback) + if len(node.elts) == 1: + elts = elts + "," + return f"({elts})" + + +def _parse_name(node: Name, _: Callback) -> str: + return node.id + + +def _parse_constant(node: Constant, _: Callback) -> str: + if node.value is Ellipsis: + return "..." + if isinstance(node.value, str): + return f"{node.value!r}" + return str(node.value) + + +PARSE_EXPR_FUNCTIONS: list[tuple[type, Callable[..., str]]] = [ + (Attribute, _parse_attribute), + (Subscript, _parse_subscript), + (Constant, _parse_constant), + (Starred, _parse_starred), + (Call, _parse_call), + (List, _parse_list), + (Tuple, _parse_tuple), + (Name, _parse_name), +] + + +def parse_expr(expr: ast.expr, callback: Callback = None) -> str: + """Return the string expression for an expr.""" + if callback and isinstance(expr_str := callback(expr), str): + return expr_str + for type_, parse in PARSE_EXPR_FUNCTIONS: + if isinstance(expr, type_): + return parse(expr, callback) + return ast.unparse(expr) diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index 5c5cc6f1..9a9133d7 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -92,6 +92,9 @@ def iter_assign_nodes(node: Module | ClassDef) -> Iterator[Assign_]: assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): if _is_assign_name(child): + if assign_node: + yield assign_node + child.__doc__ = None assign_node = child else: if assign_node: @@ -128,33 +131,33 @@ def get_assign_names(node: Module | ClassDef) -> dict[str, str | None]: return dict(iter_assign_names(node)) -def iter_def_nodes(node: Module | ClassDef) -> Iterator[Def]: +def iter_definition_nodes(node: Module | ClassDef) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): if isinstance(child, Def): yield child -def get_def_nodes(node: Module | ClassDef) -> list[Def]: +def get_definition_nodes(node: Module | ClassDef) -> list[Def]: """Return a list of definition nodes.""" - return list(iter_def_nodes(node)) + return list(iter_definition_nodes(node)) -def iter_def_names(node: Module | ClassDef) -> Iterator[str]: +def iter_definition_names(node: Module | ClassDef) -> Iterator[str]: """Yield definition node names.""" - for child in iter_def_nodes(node): + for child in iter_definition_nodes(node): yield child.name -def get_def_names(node: Module | ClassDef) -> list[str]: +def get_definition_names(node: Module | ClassDef) -> list[str]: """Return a list of definition node names.""" - return list(iter_def_names(node)) + return list(iter_definition_names(node)) def iter_nodes(node: Module | ClassDef) -> Iterator[Def | Assign_]: """Yield nodes.""" yield from iter_assign_nodes(node) - yield from iter_def_nodes(node) + yield from iter_definition_nodes(node) def get_nodes(node: Module | ClassDef) -> list[Def | Assign_]: @@ -167,7 +170,7 @@ def iter_names(node: Module | ClassDef) -> Iterator[tuple[str, str]]: yield from iter_import_names(node) for name, _ in iter_assign_names(node): yield name, f".{name}" - for name in iter_def_names(node): + for name in iter_definition_names(node): yield name, f".{name}" @@ -195,16 +198,14 @@ def get_docstring(node: Doc) -> str | None: } -def iter_arguments(node: FuncDef) -> Iterator[tuple[ast.arg, _ParameterKind]]: - """Yield arguments from the function node.""" +def _iter_arguments(node: FuncDef) -> Iterator[tuple[ast.arg, _ParameterKind]]: for kind, attr in ARGS_KIND.items(): if args := getattr(node.args, attr): it = args if isinstance(args, list) else [args] yield from ((arg, kind) for arg in it) -def iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: - """Yield defaults from the function node.""" +def _iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: args = node.args num_positional = len(args.posonlyargs) + len(args.args) nones = [None] * num_positional @@ -214,6 +215,7 @@ def iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: @dataclass class _Argument: + name: str annotation: ast.expr | None default: ast.expr | None kind: _ParameterKind @@ -221,20 +223,121 @@ class _Argument: @dataclass class _Arguments: - _args: dict[str, _Argument] + _args: list[_Argument] def __getattr__(self, name: str) -> _Argument: - return self._args[name] + names = [arg.name for arg in self._args] + return self._args[names.index(name)] + def __iter__(self) -> Iterator[_Argument]: + yield from self._args -def get_arguments(node: FuncDef) -> _Arguments: - """Return a dictionary of the function arguments.""" - it = iter_defaults(node) - args: dict[str, _Argument] = {} - for arg, kind in iter_arguments(node): + +def iter_arguments(node: FuncDef) -> Iterator[_Argument]: + """Yield arguments from the function node.""" + it = _iter_defaults(node) + for arg, kind in _iter_arguments(node): if kind in [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]: default = None else: default = next(it) - args[arg.arg] = _Argument(arg.annotation, default, kind) - return _Arguments(args) + yield _Argument(arg.arg, arg.annotation, default, kind) + + +def get_arguments(node: FuncDef) -> _Arguments: + """Return the function arguments.""" + return _Arguments(list(iter_arguments(node))) + + +@dataclass +class _Assign: + name: str + annotation: ast.expr | None + value: ast.expr | None + docstring: str | None + + +@dataclass +class _Assigns: + _assigns: list[_Assign] + + def __getattr__(self, name: str) -> _Assign: + names = [assign.name for assign in self._assigns] + return self._assigns[names.index(name)] + + def __iter__(self) -> Iterator[_Assign]: + yield from self._assigns + + +def iter_assigns(node: Module | ClassDef) -> Iterator[_Assign]: + """Yield assign nodes.""" + for assign in iter_assign_nodes(node): + if not (name := get_name(assign)): + continue + annotation = assign.annotation if isinstance(assign, AnnAssign) else None + docstring = get_docstring(assign) + yield _Assign(name, annotation, assign.value, docstring) + + +def get_assigns(node: Module | ClassDef) -> _Assigns: + """Return assigns in module or class definition.""" + return _Assigns(list(iter_assigns(node))) + + +@dataclass +class _Class: + name: str + bases: list[ast.expr] + docstring: str | None + args: _Arguments | None + attrs: _Assigns + + +@dataclass +class _Function: + name: str + docstring: str | None + args: _Arguments + returns: ast.expr | None + kind: type[FuncDef] + + +def iter_definitions(module: Module) -> Iterator[_Class | _Function]: + """Yield classes or functions.""" + for node in iter_definition_nodes(module): + name = node.name + docstring = get_docstring(node) + if isinstance(node, ClassDef): + args = _Arguments([]) + attrs = get_assigns(node) + yield _Class(name, node.bases, docstring, None, attrs) + else: + args = get_arguments(node) + yield _Function(name, docstring, args, node.returns, type(node)) + + +def get_definitions(module: Module) -> list[_Class | _Function]: + """Return a list of classes or functions.""" + return list(get_definitions(module)) + + +@dataclass +class _Module: + docstring: str | None + attrs: _Assigns + classes: list[_Class] + functions: list[_Function] + + +def get_module(node: Module) -> _Module: + """Return a [_Module] instance.""" + docstring = get_docstring(node) + attrs = get_assigns(node) + classes: list[_Class] = [] + functions: list[_Function] = [] + for obj in iter_definitions(node): + if isinstance(obj, _Class): + classes.append(obj) + else: + functions.append(obj) + return _Module(docstring, attrs, classes, functions) diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 9aa1fcd6..901a7d41 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -1,126 +1,74 @@ -"""Modules.""" -from __future__ import annotations - -import ast -import re -from dataclasses import dataclass -from importlib.util import find_spec -from pathlib import Path -from typing import TYPE_CHECKING, TypeAlias - -import mkapi.ast.node -from mkapi import config - -if TYPE_CHECKING: - from collections.abc import Iterator - -Def: TypeAlias = ast.AsyncFunctionDef | ast.FunctionDef | ast.ClassDef -Assign_: TypeAlias = ast.Assign | ast.AnnAssign -Node: TypeAlias = Def | Assign_ - - -@dataclass -class Module: - """Module class.""" - - name: str - path: Path - source: str - mtime: float - node: ast.Module - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name!r})" - - def is_package(self) -> bool: - """Return True if the module is a package.""" - return self.path.stem == "__init__" - - def update(self) -> None: - """Update contents.""" - - def get_node(self, name: str) -> Node: - """Return a node by name.""" - nodes = mkapi.ast.node.get_nodes(self.node) - node = mkapi.ast.node.get_by_name(nodes, name) - if node is None: - raise NameError - return node - - def get_names(self) -> dict[str, str]: - """Return a dictionary of names as (name => fullname).""" - return dict(mkapi.ast.node.iter_names(self.node)) - - def iter_submodules(self) -> Iterator[Module]: - """Yield submodules.""" - if self.is_package(): - for module in iter_submodules(self): - yield module - yield from module.iter_submodules() - - def get_tree(self) -> tuple[Module, list]: - """Return the package tree structure.""" - modules: list[Module | tuple[Module, list]] = [] - for module in find_submodules(self): - if module.is_package(): - modules.append(module.get_tree()) - else: - modules.append(module) - return (self, modules) - - def get_markdown(self, filters: list[str] | None) -> str: - """Return the markdown text of the module.""" - return f"# {self.name}\n\n## {self.name}\n" - - -cache: dict[str, Module] = {} - - -def get_module(name: str) -> Module: - """Return a [Module] instance by name.""" - spec = find_spec(name) - if not spec or not spec.origin: - raise ModuleNotFoundError - path = Path(spec.origin) - mtime = path.stat().st_mtime - if name in cache and mtime == cache[name].mtime: - return cache[name] - if not path.exists(): - raise ModuleNotFoundError - with path.open(encoding="utf-8") as f: - source = f.read() - node = ast.parse(source) - cache[name] = Module(name, path, source, mtime, node) - return cache[name] - - -def _is_module(path: Path) -> bool: - path_str = path.as_posix() - for pattern in config.exclude: - if re.search(pattern, path_str): - return False - it = (p.name for p in path.iterdir()) - if path.is_dir() and "__init__.py" in it: - return True - if path.is_file() and not path.stem.startswith("__") and path.suffix == ".py": - return True - return False - - -def iter_submodules(module: Module) -> Iterator[Module]: - """Yield submodules.""" - spec = find_spec(module.name) - if not spec or not spec.submodule_search_locations: - return - for location in spec.submodule_search_locations: - for path in Path(location).iterdir(): - if _is_module(path): - name = f"{module.name}.{path.stem}" - yield get_module(name) - - -def find_submodules(module: Module) -> list[Module]: - """Return a list of submodules.""" - modules = iter_submodules(module) - return sorted(modules, key=lambda module: not module.is_package()) +# """Modules.""" +# from __future__ import annotations + +# import ast +# import re +# from dataclasses import dataclass +# from importlib.util import find_spec +# from pathlib import Path +# from typing import TYPE_CHECKING, TypeAlias + +# import mkapi.ast.node +# from mkapi import config + +# if TYPE_CHECKING: +# from collections.abc import Iterator + +# Def: TypeAlias = ast.AsyncFunctionDef | ast.FunctionDef | ast.ClassDef +# Assign_: TypeAlias = ast.Assign | ast.AnnAssign +# Node: TypeAlias = Def | Assign_ + + +# @dataclass +# class Module: +# """Module class.""" + +# name: str +# path: Path +# source: str +# mtime: float +# node: ast.Module + +# def __repr__(self) -> str: +# class_name = self.__class__.__name__ +# return f"{class_name}({self.name!r})" + +# def is_package(self) -> bool: +# """Return True if the module is a package.""" +# return self.path.stem == "__init__" + +# def update(self) -> None: +# """Update contents.""" + +# def get_node(self, name: str) -> Node: +# """Return a node by name.""" +# nodes = mkapi.ast.node.get_nodes(self.node) +# node = mkapi.ast.node.get_by_name(nodes, name) +# if node is None: +# raise NameError +# return node + +# def get_names(self) -> dict[str, str]: +# """Return a dictionary of names as (name => fullname).""" +# return dict(mkapi.ast.node.iter_names(self.node)) + +# def iter_submodules(self) -> Iterator[Module]: +# """Yield submodules.""" +# if self.is_package(): +# for module in iter_submodules(self): +# yield module +# yield from module.iter_submodules() + +# def get_tree(self) -> tuple[Module, list]: +# """Return the package tree structure.""" +# modules: list[Module | tuple[Module, list]] = [] +# for module in find_submodules(self): +# if module.is_package(): +# modules.append(module.get_tree()) +# else: +# modules.append(module) +# return (self, modules) + +# def get_markdown(self, filters: list[str] | None) -> str: +# """Return the markdown text of the module.""" +# return f"# {self.name}\n\n## {self.name}\n" diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 3218584d..e7ed9bd7 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,7 +5,7 @@ # from dataclasses import dataclass # from typing import TYPE_CHECKING, TypeAlias -# from mkapi.ast import iter_def_nodes +# from mkapi.ast import iter_definition_nodes # if TYPE_CHECKING: # from collections.abc import Iterator diff --git a/src/mkapi/parser.py b/src/mkapi/parser.py deleted file mode 100644 index fa53b3e1..00000000 --- a/src/mkapi/parser.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Parser module.""" -from __future__ import annotations - -import ast -from ast import Attribute, Constant, List, Name, Subscript, Tuple -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from ast import AST - from collections.abc import Callable - - -def parse_attribute(node: Attribute) -> str: # noqa: D103 - return ".".join([parse_node(node.value), node.attr]) - - -def parse_subscript(node: Subscript) -> str: # noqa: D103 - value = parse_node(node.value) - slice_ = parse_node(node.slice) - return f"{value}[{slice_}]" - - -def parse_constant(node: Constant) -> str: # noqa: D103 - if node.value is Ellipsis: - return "..." - if isinstance(node.value, str): - return node.value - return parse_value(node.value) - - -def parse_list(node: List) -> str: # noqa: D103 - return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" - - -def parse_tuple(node: Tuple) -> str: # noqa: D103 - return ", ".join(parse_node(n) for n in node.elts) - - -def parse_value(value: Any) -> str: # noqa: D103, ANN401 - return str(value) - - -PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ - (Attribute, parse_attribute), - (Subscript, parse_subscript), - (Constant, parse_constant), - (List, parse_list), - (Tuple, parse_tuple), - (Name, "id"), -] - - -def parse_node(node: AST) -> str: - """Return the string expression for an AST node.""" - for type_, parse in PARSE_NODE_FUNCTIONS: - if isinstance(node, type_): - node_str = parse(node) if callable(parse) else getattr(node, parse) - return node_str if isinstance(node_str, str) else str(node_str) - return ast.unparse(node) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 88388d1d..22f43820 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -30,7 +30,8 @@ import mkapi.config from mkapi.filter import split_filters, update_filters -from mkapi.modules import Module, get_module + +# from mkapi.modules import Module, get_module # from mkapi.core.module import Module, get_module # from mkapi.core.object import get_object diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py new file mode 100644 index 00000000..20cfc1ae --- /dev/null +++ b/src/mkapi/utils.py @@ -0,0 +1,67 @@ +"""Utility code.""" +from __future__ import annotations + +import ast +import re +from importlib.util import find_spec +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + +module_cache: dict[str, tuple[float, ast.Module]] = {} + + +def get_module_node(name: str) -> ast.Module: + """Return a [Module] node by name.""" + spec = find_spec(name) + if not spec or not spec.origin: + raise ModuleNotFoundError + path = Path(spec.origin) + mtime = path.stat().st_mtime + if name in module_cache and mtime == module_cache[name][0]: + return module_cache[name][1] + if not path.exists(): + raise ModuleNotFoundError + with path.open(encoding="utf-8") as f: + source = f.read() + node = ast.parse(source) + module_cache[name] = (mtime, node) + return node + + +def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: + path_str = path.as_posix() + for pattern in exclude_patterns: + if re.search(pattern, path_str): + return False + it = (p.name for p in path.iterdir()) + if path.is_dir() and "__init__.py" in it: + return True + if path.is_file() and not path.stem.startswith("__") and path.suffix == ".py": + return True + return False + + +def _is_package(name: str) -> bool: + if (spec := find_spec(name)) and spec.origin: + return Path(spec.origin).stem == "__init__" + return False + + +def iter_submodule_names(name: str) -> Iterator[str]: + """Yield submodule names.""" + spec = find_spec(name) + if not spec or not spec.submodule_search_locations: + return + for location in spec.submodule_search_locations: + for path in Path(location).iterdir(): + if _is_module(path): + yield f"{name}.{path.stem}" + + +def find_submodule_names(name: str) -> list[str]: + """Return a list of submodules.""" + names = iter_submodule_names(name) + return sorted(names, key=lambda name: not _is_package(name)) diff --git a/tests/ast/test_assign.py b/tests/ast/test_assign.py new file mode 100644 index 00000000..df2c6b40 --- /dev/null +++ b/tests/ast/test_assign.py @@ -0,0 +1,27 @@ +import ast + +from mkapi.ast.node import get_assigns + + +def _get_assigns(source: str): + node = ast.parse(source).body[0] + assert isinstance(node, ast.ClassDef) + return get_assigns(node) + + +def test_get_assign(): + src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" + x = _get_assigns(src).x + assert x.annotation is None + assert isinstance(x.value, ast.Call) + assert ast.unparse(x.value.func) == "f.g" + assert x.docstring == "docstring" + src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" + assigns = _get_assigns(src) + x, y, z = assigns._assigns # noqa: SLF001 + assert x.docstring is None + assert x.value is None + assert y.docstring == "docstring\na" + assert z.docstring is None + assert z.value is not None + assert list(assigns) == [x, y, z] diff --git a/tests/ast/test_expr.py b/tests/ast/test_expr.py new file mode 100644 index 00000000..5869a4d6 --- /dev/null +++ b/tests/ast/test_expr.py @@ -0,0 +1,64 @@ +import ast + +import pytest + +from mkapi.ast.expr import parse_expr + + +def _expr(src: str) -> str: + expr = ast.parse(src).body[0] + assert isinstance(expr, ast.Expr) + return parse_expr(expr.value) + + +@pytest.mark.parametrize("x", ["1", "'a'", "...", "None", "True"]) +def test_parse_expr_constant(x): + assert _expr(x) == x + + +@pytest.mark.parametrize("x", ["()", "(1,)", "(1, 2)"]) +def test_parse_expr_tuple(x): + assert _expr(x.replace(" ", "")) == x + + +@pytest.mark.parametrize("x", ["[]", "[1]", "[1, 2]"]) +def test_parse_expr_list(x): + assert _expr(x.replace(" ", "")) == x + + +def test_parse_expr_attribute(): + assert _expr("a.b.c") == "a.b.c" + + +@pytest.mark.parametrize("x", ["1:2", "1:", ":-2", "1:10:2", "1::2", "::3", ":3:2"]) +def test_parse_expr_slice(x): + x = f"a[{x}]" + assert _expr(x) == x + + +def test_parse_expr_subscript(): + assert _expr("a[1]") == "a[1]" + assert _expr("a[1,2.0,'c']") == "a[1, 2.0, 'c']" + + +def test_parse_expr_call(): + assert _expr("f()") == "f()" + assert _expr("f(a)") == "f(a)" + assert _expr("f(a,b=c(),*d,**e[1])") == "f(a, *d, b=c(), **e[1])" + + +def test_callback(): + def callback(expr: ast.expr): + if isinstance(expr, ast.Name): + return f"<{expr.id}>" + return None + + expr = ast.parse("a[b]").body[0] + assert isinstance(expr, ast.Expr) + assert parse_expr(expr.value, callback) == "[]" + expr = ast.parse("a.b.c").body[0] + assert isinstance(expr, ast.Expr) + assert parse_expr(expr.value, callback) == ".b.c" + expr = ast.parse("a(1).b[c].d").body[0] + assert isinstance(expr, ast.Expr) + assert parse_expr(expr.value, callback) == "(1).b[].d" diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 1ef4f11f..5a72fceb 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -1,4 +1,5 @@ import ast +from ast import Module import pytest @@ -6,23 +7,23 @@ get_assign_names, get_assign_nodes, get_by_name, - get_def_nodes, + get_definition_nodes, get_docstring, get_import_names, get_name, get_names, iter_import_nodes, ) -from mkapi.modules import Module, get_module +from mkapi.utils import get_module_node @pytest.fixture(scope="module") def module(): - return get_module("mkdocs.structure.files") + return get_module_node("mkdocs.structure.files") def test_iter_import_nodes(module: Module): - node = next(iter_import_nodes(module.node)) + node = next(iter_import_nodes(module)) assert isinstance(node, ast.ImportFrom) assert len(node.names) == 1 alias = node.names[0] @@ -32,7 +33,7 @@ def test_iter_import_nodes(module: Module): def test_get_import_names(module: Module): - names = get_import_names(module.node) + names = get_import_names(module) assert "logging" in names assert names["logging"] == "logging" assert "PurePath" in names @@ -56,16 +57,16 @@ def test_get_by_name(def_nodes): @pytest.fixture(scope="module") def def_nodes(module: Module): - return get_def_nodes(module.node) + return get_definition_nodes(module) -def test_get_def_nodes(def_nodes): +def test_get_definition_nodes(def_nodes): assert any(node.name == "get_files" for node in def_nodes) assert any(node.name == "Files" for node in def_nodes) def test_get_assign_names(module: Module, def_nodes): - names = get_assign_names(module.node) + names = get_assign_names(module) assert names["log"] is not None assert names["log"].startswith("logging.getLogger") node = get_by_name(def_nodes, "InclusionLevel") @@ -76,7 +77,7 @@ def test_get_assign_names(module: Module, def_nodes): def test_get_names(module: Module, def_nodes): - names = get_names(module.node) + names = get_names(module) assert names["Callable"] == "typing.Callable" assert names["File"] == ".File" assert names["get_files"] == ".get_files" diff --git a/tests/test_inspect.py b/tests/test_inspect.py index c9b9d0cf..03ff7950 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -1,13 +1,12 @@ -import ast +# import ast -import pytest +# import pytest -from mkapi.modules import get_module +# from mkapi.modules import get_module -def test_(): - module = get_module("mkdocs.commands.build") - names = module.get_names() - node = module.get_node("get_context") - print(ast.unparse(node)) - # assert 0 +# def test_(): +# module = get_module("mkdocs.commands.build") +# names = module.get_names() +# node = module.get_node("get_context") +# print(ast.unparse(node)) diff --git a/tests/test_modules.py b/tests/test_modules.py index 20ca9b0c..e69de29b 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,44 +0,0 @@ -import pytest - -from mkapi import config -from mkapi.modules import Module, cache, find_submodules, get_module - - -@pytest.fixture(scope="module") -def module(): - return get_module("mkdocs") - - -def test_get_module(module: Module): - assert module.is_package() - assert len(find_submodules(module)) > 0 - module = get_module("mkdocs.structure.files") - assert not module.is_package() - assert not find_submodules(module) - assert module.mtime > 1703851000 - assert module.path.stem == "files" - - -def test_iter_submodules(module: Module): - exclude = config.exclude.copy() - assert len(list(module.iter_submodules())) < 45 - config.exclude.clear() - assert len(list(module.iter_submodules())) > 45 - config.exclude = exclude - - -def test_get_tree(): - module = get_module("mkdocs.structure.files") - tree = module.get_tree() - assert isinstance(tree[0], Module) - assert tree[1] == [] - module = get_module("mkdocs.structure") - tree = module.get_tree() - assert isinstance(tree[0], Module) - assert len(tree[1]) - assert isinstance(tree[1][0], Module) - - -def test_cache(module: Module): - assert "mkdocs" in cache - assert id(get_module("mkdocs")) == id(module) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9e126c0d..8eba013c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -73,10 +73,10 @@ def test_mkapi_config(mkapi_config: MkAPIConfig): # return mkdocs_config.theme.get_env() -def test_mkdocs_build(mkdocs_config: MkDocsConfig): - config = mkdocs_config - config.plugins.on_startup(command="build", dirty=False) - try: - build(config, dirty=False) - finally: - config.plugins.on_shutdown() +# def test_mkdocs_build(mkdocs_config: MkDocsConfig): +# config = mkdocs_config +# config.plugins.on_startup(command="build", dirty=False) +# try: +# build(config, dirty=False) +# finally: +# config.plugins.on_shutdown() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..a36c2c54 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,20 @@ +from ast import Module + +from mkapi.utils import find_submodule_names, get_module_node, module_cache + + +def test_get_module_node(): + node = get_module_node("mkdocs") + assert isinstance(node, Module) + + +def test_find_submodule_names(): + names = find_submodule_names("mkdocs") + assert "mkdocs.commands" in names + assert "mkdocs.plugins" in names + + +def test_module_cache(): + node1 = get_module_node("mkdocs") + node2 = get_module_node("mkdocs") + assert node1 is node2 From c375ed8e83c17bb4903791a89f70b752d403f0a9 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 21:57:49 +0900 Subject: [PATCH 040/148] _Items --- src/mkapi/ast/eval.py | 2 + src/mkapi/ast/expr.py | 4 +- src/mkapi/ast/node.py | 204 +++++++++++------- src/mkapi/utils.py | 21 -- tests/ast/{test_arguments.py => test_args.py} | 2 +- tests/ast/{test_assign.py => test_attrs.py} | 14 +- tests/ast/test_items.py | 23 ++ tests/ast/test_node.py | 13 +- tests/test_utils.py | 15 +- 9 files changed, 174 insertions(+), 124 deletions(-) create mode 100644 src/mkapi/ast/eval.py rename tests/ast/{test_arguments.py => test_args.py} (95%) rename tests/ast/{test_assign.py => test_attrs.py} (68%) create mode 100644 tests/ast/test_items.py diff --git a/src/mkapi/ast/eval.py b/src/mkapi/ast/eval.py new file mode 100644 index 00000000..f28fb57e --- /dev/null +++ b/src/mkapi/ast/eval.py @@ -0,0 +1,2 @@ +"""Evaluation module.""" +from __future__ import annotations diff --git a/src/mkapi/ast/expr.py b/src/mkapi/ast/expr.py index 9ab7f9e9..35949296 100644 --- a/src/mkapi/ast/expr.py +++ b/src/mkapi/ast/expr.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: from typing import TypeAlias -Callback: TypeAlias = Callable[[ast.expr], str | ast.expr | None] | None -# type Callback = Callable[[ast.expr], str | ast.expr] # Python 3.12 +# Callback: TypeAlias = Callable[[ast.expr], str | ast.expr | None] | None +type Callback = Callable[[ast.expr], str | ast.expr] # Python 3.12 def _parse_attribute(node: Attribute, callback: Callback) -> str: diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index 9a9133d7..d6088805 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -4,7 +4,6 @@ import ast from ast import ( AnnAssign, - Assign, AsyncFunctionDef, ClassDef, Constant, @@ -12,11 +11,12 @@ FunctionDef, Import, ImportFrom, - Module, Name, ) from dataclasses import dataclass +from importlib.util import find_spec from inspect import Parameter, cleandoc +from pathlib import Path from typing import TYPE_CHECKING, TypeAlias, TypeGuard if TYPE_CHECKING: @@ -27,8 +27,28 @@ Import_: TypeAlias = Import | ImportFrom FuncDef: TypeAlias = AsyncFunctionDef | FunctionDef Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef -Assign_: TypeAlias = Assign | AnnAssign -Doc: TypeAlias = Module | Def | Assign_ +Assign_: TypeAlias = ast.Assign | AnnAssign +Doc: TypeAlias = ast.Module | Def | Assign_ + +module_cache: dict[str, tuple[float, ast.Module]] = {} + + +def get_module_node(name: str) -> ast.Module: + """Return a [ast.Module] node by name.""" + spec = find_spec(name) + if not spec or not spec.origin: + raise ModuleNotFoundError + path = Path(spec.origin) + mtime = path.stat().st_mtime + if name in module_cache and mtime == module_cache[name][0]: + return module_cache[name][1] + if not path.exists(): + raise ModuleNotFoundError + with path.open(encoding="utf-8") as f: + source = f.read() + node = ast.parse(source) + module_cache[name] = (mtime, node) + return node def iter_import_nodes(node: AST) -> Iterator[Import_]: @@ -40,7 +60,7 @@ def iter_import_nodes(node: AST) -> Iterator[Import_]: yield from iter_import_nodes(child) -def iter_import_names(node: Module | Def) -> Iterator[tuple[str, str]]: +def iter_import_names(node: ast.Module | Def) -> Iterator[tuple[str, str]]: """Yield imported names.""" for child in iter_import_nodes(node): from_module = f"{child.module}." if isinstance(child, ImportFrom) else "" @@ -50,7 +70,7 @@ def iter_import_names(node: Module | Def) -> Iterator[tuple[str, str]]: yield name, fullname -def get_import_names(node: Module | Def) -> dict[str, str]: +def get_import_names(node: ast.Module | Def) -> dict[str, str]: """Return a dictionary of imported names as (name => fullname).""" return dict(iter_import_names(node)) @@ -58,7 +78,7 @@ def get_import_names(node: Module | Def) -> dict[str, str]: def _is_assign_name(node: AST) -> TypeGuard[Assign_]: if isinstance(node, AnnAssign) and isinstance(node.target, Name): return True - if isinstance(node, Assign) and isinstance(node.targets[0], Name): + if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): return True return False @@ -67,7 +87,7 @@ def _get_assign_name(node: AST) -> str | None: """Return the name of the assign node.""" if isinstance(node, AnnAssign) and isinstance(node.target, Name): return node.target.id - if isinstance(node, Assign) and isinstance(node.targets[0], Name): + if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): return node.targets[0].id return None @@ -87,7 +107,7 @@ def get_by_name(nodes: Sequence[Def | Assign_], name: str) -> Def | Assign_ | No return None -def iter_assign_nodes(node: Module | ClassDef) -> Iterator[Assign_]: +def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: """Yield assign nodes.""" assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): @@ -113,12 +133,12 @@ def _get_docstring(node: AST) -> str | None: return cleandoc(doc) if isinstance(doc, str) else None -def get_assign_nodes(node: Module | ClassDef) -> list[Assign_]: +def get_assign_nodes(node: ast.Module | ClassDef) -> list[Assign_]: """Return a list of assign nodes.""" return list(iter_assign_nodes(node)) -def iter_assign_names(node: Module | ClassDef) -> Iterator[tuple[str, str | None]]: +def iter_assign_names(node: ast.Module | ClassDef) -> Iterator[tuple[str, str | None]]: """Yield assign node names.""" for child in iter_assign_nodes(node): if name := _get_assign_name(child): @@ -126,46 +146,46 @@ def iter_assign_names(node: Module | ClassDef) -> Iterator[tuple[str, str | None yield name, fullname -def get_assign_names(node: Module | ClassDef) -> dict[str, str | None]: +def get_assign_names(node: ast.Module | ClassDef) -> dict[str, str | None]: """Return a dictionary of assigned names as (name => fullname).""" return dict(iter_assign_names(node)) -def iter_definition_nodes(node: Module | ClassDef) -> Iterator[Def]: +def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): if isinstance(child, Def): yield child -def get_definition_nodes(node: Module | ClassDef) -> list[Def]: +def get_definition_nodes(node: ast.Module | ClassDef) -> list[Def]: """Return a list of definition nodes.""" return list(iter_definition_nodes(node)) -def iter_definition_names(node: Module | ClassDef) -> Iterator[str]: +def iter_definition_names(node: ast.Module | ClassDef) -> Iterator[str]: """Yield definition node names.""" for child in iter_definition_nodes(node): yield child.name -def get_definition_names(node: Module | ClassDef) -> list[str]: +def get_definition_names(node: ast.Module | ClassDef) -> list[str]: """Return a list of definition node names.""" return list(iter_definition_names(node)) -def iter_nodes(node: Module | ClassDef) -> Iterator[Def | Assign_]: +def iter_nodes(node: ast.Module | ClassDef) -> Iterator[Def | Assign_]: """Yield nodes.""" yield from iter_assign_nodes(node) yield from iter_definition_nodes(node) -def get_nodes(node: Module | ClassDef) -> list[Def | Assign_]: +def get_nodes(node: ast.Module | ClassDef) -> list[Def | Assign_]: """Return a list of nodes.""" return list(iter_nodes(node)) -def iter_names(node: Module | ClassDef) -> Iterator[tuple[str, str]]: +def iter_names(node: ast.Module | ClassDef) -> Iterator[tuple[str, str]]: """Yield names as (name, fullname) pairs.""" yield from iter_import_names(node) for name, _ in iter_assign_names(node): @@ -174,14 +194,14 @@ def iter_names(node: Module | ClassDef) -> Iterator[tuple[str, str]]: yield name, f".{name}" -def get_names(node: Module | ClassDef) -> dict[str, str]: +def get_names(node: ast.Module | ClassDef) -> dict[str, str]: """Return a dictionary of names as (name => fullname).""" return dict(iter_names(node)) def get_docstring(node: Doc) -> str | None: """Return the docstring for the given node or None if no docstring can be found.""" - if isinstance(node, Module | Def): + if isinstance(node, ast.Module | Def): return ast.get_docstring(node) if isinstance(node, Assign_): return node.__doc__ @@ -214,26 +234,44 @@ def _iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: @dataclass -class _Argument: +class _Item: name: str + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name!r})" + + +@dataclass +class _Items[T]: + items: list[T] + + def __getattr__(self, name: str) -> T: + names = [elem.name for elem in self.items] # type: ignore # noqa: PGH003 + return self.items[names.index(name)] + + def __iter__(self) -> Iterator[T]: + return iter(self.items) + + def __repr__(self) -> str: + names = ", ".join(f"{elem.name!r}" for elem in self.items) # type: ignore # noqa: PGH003 + return f"{self.__class__.__name__}({names})" + + +@dataclass(repr=False) +class Argument(_Item): + """Argument class.""" + annotation: ast.expr | None default: ast.expr | None kind: _ParameterKind @dataclass -class _Arguments: - _args: list[_Argument] - - def __getattr__(self, name: str) -> _Argument: - names = [arg.name for arg in self._args] - return self._args[names.index(name)] - - def __iter__(self) -> Iterator[_Argument]: - yield from self._args +class Arguments(_Items[Argument]): + """Arguments class.""" -def iter_arguments(node: FuncDef) -> Iterator[_Argument]: +def iter_arguments(node: FuncDef) -> Iterator[Argument]: """Yield arguments from the function node.""" it = _iter_defaults(node) for arg, kind in _iter_arguments(node): @@ -241,103 +279,113 @@ def iter_arguments(node: FuncDef) -> Iterator[_Argument]: default = None else: default = next(it) - yield _Argument(arg.arg, arg.annotation, default, kind) + yield Argument(arg.arg, arg.annotation, default, kind) -def get_arguments(node: FuncDef) -> _Arguments: +def get_arguments(node: FuncDef) -> Arguments: """Return the function arguments.""" - return _Arguments(list(iter_arguments(node))) + return Arguments(list(iter_arguments(node))) -@dataclass -class _Assign: - name: str +@dataclass(repr=False) +class Attribute(_Item): + """Attribute class.""" + annotation: ast.expr | None value: ast.expr | None docstring: str | None -@dataclass -class _Assigns: - _assigns: list[_Assign] - - def __getattr__(self, name: str) -> _Assign: - names = [assign.name for assign in self._assigns] - return self._assigns[names.index(name)] +@dataclass(repr=False) +class Attributes(_Items[Attribute]): + """Assigns class.""" - def __iter__(self) -> Iterator[_Assign]: - yield from self._assigns - -def iter_assigns(node: Module | ClassDef) -> Iterator[_Assign]: +def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: """Yield assign nodes.""" for assign in iter_assign_nodes(node): if not (name := get_name(assign)): continue annotation = assign.annotation if isinstance(assign, AnnAssign) else None docstring = get_docstring(assign) - yield _Assign(name, annotation, assign.value, docstring) + yield Attribute(name, annotation, assign.value, docstring) -def get_assigns(node: Module | ClassDef) -> _Assigns: +def get_attributes(node: ast.Module | ClassDef) -> Attributes: """Return assigns in module or class definition.""" - return _Assigns(list(iter_assigns(node))) + return Attributes(list(iter_attributes(node))) -@dataclass -class _Class: - name: str +@dataclass(repr=False) +class Class(_Item): + """Class class.""" + bases: list[ast.expr] docstring: str | None - args: _Arguments | None - attrs: _Assigns + args: Arguments | None + attrs: Attributes -@dataclass -class _Function: - name: str +@dataclass(repr=False) +class Classes(_Items[Class]): + """Classes class.""" + + +@dataclass(repr=False) +class Function(_Item): + """Function class.""" + docstring: str | None - args: _Arguments + args: Arguments returns: ast.expr | None kind: type[FuncDef] -def iter_definitions(module: Module) -> Iterator[_Class | _Function]: +@dataclass(repr=False) +class Functions(_Items[Function]): + """Functions class.""" + + +def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: """Yield classes or functions.""" for node in iter_definition_nodes(module): name = node.name docstring = get_docstring(node) if isinstance(node, ClassDef): - args = _Arguments([]) - attrs = get_assigns(node) - yield _Class(name, node.bases, docstring, None, attrs) + args = Arguments([]) + attrs = get_attributes(node) + yield Class(name, node.bases, docstring, None, attrs) else: args = get_arguments(node) - yield _Function(name, docstring, args, node.returns, type(node)) + yield Function(name, docstring, args, node.returns, type(node)) -def get_definitions(module: Module) -> list[_Class | _Function]: +def get_definitions(module: ast.Module) -> list[Class | Function]: """Return a list of classes or functions.""" return list(get_definitions(module)) @dataclass -class _Module: +class Module: + """Module class.""" + docstring: str | None - attrs: _Assigns - classes: list[_Class] - functions: list[_Function] + attrs: Attributes + classes: Classes + functions: Functions + globals: dict[str, str] # noqa: A003 -def get_module(node: Module) -> _Module: - """Return a [_Module] instance.""" +def get_module(node: ast.Module) -> Module: + """Return a [Module] instance.""" docstring = get_docstring(node) - attrs = get_assigns(node) - classes: list[_Class] = [] - functions: list[_Function] = [] + attrs = get_attributes(node) + globals = get_names(node) # noqa: A001 + classes: list[Class] = [] + functions: list[Function] = [] for obj in iter_definitions(node): - if isinstance(obj, _Class): + if isinstance(obj, Class): classes.append(obj) else: functions.append(obj) - return _Module(docstring, attrs, classes, functions) + return Module(docstring, attrs, Classes(classes), Functions(functions), globals) diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 20cfc1ae..a5414b2f 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -1,7 +1,6 @@ """Utility code.""" from __future__ import annotations -import ast import re from importlib.util import find_spec from pathlib import Path @@ -10,26 +9,6 @@ if TYPE_CHECKING: from collections.abc import Iterable, Iterator -module_cache: dict[str, tuple[float, ast.Module]] = {} - - -def get_module_node(name: str) -> ast.Module: - """Return a [Module] node by name.""" - spec = find_spec(name) - if not spec or not spec.origin: - raise ModuleNotFoundError - path = Path(spec.origin) - mtime = path.stat().st_mtime - if name in module_cache and mtime == module_cache[name][0]: - return module_cache[name][1] - if not path.exists(): - raise ModuleNotFoundError - with path.open(encoding="utf-8") as f: - source = f.read() - node = ast.parse(source) - module_cache[name] = (mtime, node) - return node - def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: path_str = path.as_posix() diff --git a/tests/ast/test_arguments.py b/tests/ast/test_args.py similarity index 95% rename from tests/ast/test_arguments.py rename to tests/ast/test_args.py index 4afbf47a..e6ca5985 100644 --- a/tests/ast/test_arguments.py +++ b/tests/ast/test_args.py @@ -12,7 +12,7 @@ def _get_args(source: str): def test_get_arguments_1(): args = _get_args("def f():\n pass") - assert not args._args # noqa: SLF001 + assert not args.items args = _get_args("def f(x):\n pass") assert args.x.annotation is None assert args.x.default is None diff --git a/tests/ast/test_assign.py b/tests/ast/test_attrs.py similarity index 68% rename from tests/ast/test_assign.py rename to tests/ast/test_attrs.py index df2c6b40..32c8d5ef 100644 --- a/tests/ast/test_assign.py +++ b/tests/ast/test_attrs.py @@ -1,24 +1,24 @@ import ast -from mkapi.ast.node import get_assigns +from mkapi.ast.node import get_attributes -def _get_assigns(source: str): +def _get_attributes(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.ClassDef) - return get_assigns(node) + return get_attributes(node) -def test_get_assign(): +def test_get_attributes(): src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" - x = _get_assigns(src).x + x = _get_attributes(src).x assert x.annotation is None assert isinstance(x.value, ast.Call) assert ast.unparse(x.value.func) == "f.g" assert x.docstring == "docstring" src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" - assigns = _get_assigns(src) - x, y, z = assigns._assigns # noqa: SLF001 + assigns = _get_attributes(src) + x, y, z = assigns.items assert x.docstring is None assert x.value is None assert y.docstring == "docstring\na" diff --git a/tests/ast/test_items.py b/tests/ast/test_items.py new file mode 100644 index 00000000..81bf0547 --- /dev/null +++ b/tests/ast/test_items.py @@ -0,0 +1,23 @@ +import ast + +import pytest + +from mkapi.ast.node import Module, get_module, get_module_node + + +@pytest.fixture(scope="module") +def module(): + node = get_module_node("mkapi.ast.node") + return get_module(node) + + +def test_args(module: Module): + print(module.classes) + assert 0 + + +def test_attrs(module: Module): + attrs = module.attrs + print(attrs) + assert 0 + # args = get_arguments diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 5a72fceb..36e4df6b 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -10,11 +10,22 @@ get_definition_nodes, get_docstring, get_import_names, + get_module_node, get_name, get_names, iter_import_nodes, ) -from mkapi.utils import get_module_node + + +def test_get_module_node(): + node = get_module_node("mkdocs") + assert isinstance(node, Module) + + +def test_module_cache(): + node1 = get_module_node("mkdocs") + node2 = get_module_node("mkdocs") + assert node1 is node2 @pytest.fixture(scope="module") diff --git a/tests/test_utils.py b/tests/test_utils.py index a36c2c54..a28b2457 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,20 +1,7 @@ -from ast import Module - -from mkapi.utils import find_submodule_names, get_module_node, module_cache - - -def test_get_module_node(): - node = get_module_node("mkdocs") - assert isinstance(node, Module) +from mkapi.utils import find_submodule_names def test_find_submodule_names(): names = find_submodule_names("mkdocs") assert "mkdocs.commands" in names assert "mkdocs.plugins" in names - - -def test_module_cache(): - node1 = get_module_node("mkdocs") - node2 = get_module_node("mkdocs") - assert node1 is node2 From 7ba0e2dbe370e25361cb9050b3cc022949b52ed0 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 22:45:05 +0900 Subject: [PATCH 041/148] TypeAlias --- src/mkapi/ast/expr.py | 5 --- src/mkapi/ast/node.py | 54 ++++++++++++++--------- tests/ast/{test_items.py => test_eval.py} | 15 +++---- 3 files changed, 41 insertions(+), 33 deletions(-) rename tests/ast/{test_items.py => test_eval.py} (58%) diff --git a/src/mkapi/ast/expr.py b/src/mkapi/ast/expr.py index 35949296..15139ca3 100644 --- a/src/mkapi/ast/expr.py +++ b/src/mkapi/ast/expr.py @@ -4,12 +4,7 @@ import ast from ast import Attribute, Call, Constant, List, Name, Slice, Starred, Subscript, Tuple from collections.abc import Callable -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from typing import TypeAlias - -# Callback: TypeAlias = Callable[[ast.expr], str | ast.expr | None] | None type Callback = Callable[[ast.expr], str | ast.expr] # Python 3.12 diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index d6088805..4f7c247f 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -12,23 +12,23 @@ Import, ImportFrom, Name, + TypeAlias, ) from dataclasses import dataclass from importlib.util import find_spec from inspect import Parameter, cleandoc from pathlib import Path -from typing import TYPE_CHECKING, TypeAlias, TypeGuard +from typing import TYPE_CHECKING, TypeGuard if TYPE_CHECKING: from ast import AST from collections.abc import Iterator, Sequence from inspect import _ParameterKind -Import_: TypeAlias = Import | ImportFrom -FuncDef: TypeAlias = AsyncFunctionDef | FunctionDef -Def: TypeAlias = AsyncFunctionDef | FunctionDef | ClassDef -Assign_: TypeAlias = ast.Assign | AnnAssign -Doc: TypeAlias = ast.Module | Def | Assign_ +type FunctionDef_ = AsyncFunctionDef | FunctionDef +type Def = FunctionDef_ | ClassDef +type Assign_ = ast.Assign | AnnAssign | TypeAlias +type Doc = ast.Module | Def | Assign_ module_cache: dict[str, tuple[float, ast.Module]] = {} @@ -51,12 +51,12 @@ def get_module_node(name: str) -> ast.Module: return node -def iter_import_nodes(node: AST) -> Iterator[Import_]: +def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: """Yield import nodes.""" for child in ast.iter_child_nodes(node): - if isinstance(child, Import_): + if isinstance(child, Import | ImportFrom): yield child - elif not isinstance(child, Def): + elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield from iter_import_nodes(child) @@ -80,6 +80,8 @@ def _is_assign_name(node: AST) -> TypeGuard[Assign_]: return True if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): return True + if isinstance(node, TypeAlias) and isinstance(node.name, Name): + return True return False @@ -89,12 +91,14 @@ def _get_assign_name(node: AST) -> str | None: return node.target.id if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): return node.targets[0].id + if isinstance(node, TypeAlias) and isinstance(node.name, Name): + return node.name.id return None def get_name(node: AST) -> str | None: """Return the node name.""" - if isinstance(node, Def): + if isinstance(node, AsyncFunctionDef | FunctionDef | ClassDef): return node.name return _get_assign_name(node) @@ -154,7 +158,7 @@ def get_assign_names(node: ast.Module | ClassDef) -> dict[str, str | None]: def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): - if isinstance(child, Def): + if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield child @@ -201,9 +205,9 @@ def get_names(node: ast.Module | ClassDef) -> dict[str, str]: def get_docstring(node: Doc) -> str | None: """Return the docstring for the given node or None if no docstring can be found.""" - if isinstance(node, ast.Module | Def): + if isinstance(node, AsyncFunctionDef | FunctionDef | ClassDef | ast.Module): return ast.get_docstring(node) - if isinstance(node, Assign_): + if isinstance(node, ast.Assign | AnnAssign | TypeAlias): return node.__doc__ msg = f"{node.__class__.__name__!r} can't have docstrings" raise TypeError(msg) @@ -218,14 +222,14 @@ def get_docstring(node: Doc) -> str | None: } -def _iter_arguments(node: FuncDef) -> Iterator[tuple[ast.arg, _ParameterKind]]: +def _iter_arguments(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: for kind, attr in ARGS_KIND.items(): if args := getattr(node.args, attr): it = args if isinstance(args, list) else [args] yield from ((arg, kind) for arg in it) -def _iter_defaults(node: FuncDef) -> Iterator[ast.expr | None]: +def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: args = node.args num_positional = len(args.posonlyargs) + len(args.args) nones = [None] * num_positional @@ -271,7 +275,7 @@ class Arguments(_Items[Argument]): """Arguments class.""" -def iter_arguments(node: FuncDef) -> Iterator[Argument]: +def iter_arguments(node: FunctionDef_) -> Iterator[Argument]: """Yield arguments from the function node.""" it = _iter_defaults(node) for arg, kind in _iter_arguments(node): @@ -282,7 +286,7 @@ def iter_arguments(node: FuncDef) -> Iterator[Argument]: yield Argument(arg.arg, arg.annotation, default, kind) -def get_arguments(node: FuncDef) -> Arguments: +def get_arguments(node: FunctionDef_) -> Arguments: """Return the function arguments.""" return Arguments(list(iter_arguments(node))) @@ -301,14 +305,24 @@ class Attributes(_Items[Attribute]): """Assigns class.""" +def get_annotation(node: Assign_) -> ast.expr | None: + """Return annotation.""" + if isinstance(node, AnnAssign): + return node.annotation + if isinstance(node, TypeAlias): + return node.value + return None + + def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: """Yield assign nodes.""" for assign in iter_assign_nodes(node): if not (name := get_name(assign)): continue - annotation = assign.annotation if isinstance(assign, AnnAssign) else None + annotation = get_annotation(assign) + value = None if isinstance(assign, TypeAlias) else assign.value docstring = get_docstring(assign) - yield Attribute(name, annotation, assign.value, docstring) + yield Attribute(name, annotation, value, docstring) def get_attributes(node: ast.Module | ClassDef) -> Attributes: @@ -338,7 +352,7 @@ class Function(_Item): docstring: str | None args: Arguments returns: ast.expr | None - kind: type[FuncDef] + kind: type[FunctionDef_] @dataclass(repr=False) diff --git a/tests/ast/test_items.py b/tests/ast/test_eval.py similarity index 58% rename from tests/ast/test_items.py rename to tests/ast/test_eval.py index 81bf0547..377b8c93 100644 --- a/tests/ast/test_items.py +++ b/tests/ast/test_eval.py @@ -1,5 +1,3 @@ -import ast - import pytest from mkapi.ast.node import Module, get_module, get_module_node @@ -12,12 +10,13 @@ def module(): def test_args(module: Module): - print(module.classes) + g = module.globals + for n in g: + print(n) assert 0 -def test_attrs(module: Module): - attrs = module.attrs - print(attrs) - assert 0 - # args = get_arguments +# def test_attrs(module: Module): +# attrs = module.attrs +# print(attrs) +# assert 0 From 0dd7f9dc806a27a8dd4767e59c9ee98094793635 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 1 Jan 2024 23:43:06 +0900 Subject: [PATCH 042/148] Remove unused functions --- src/mkapi/ast/node.py | 126 ++++++----------------------------------- tests/ast/test_eval.py | 4 +- tests/ast/test_node.py | 77 +++++-------------------- 3 files changed, 33 insertions(+), 174 deletions(-) diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index 4f7c247f..4fc54300 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -18,11 +18,11 @@ from importlib.util import find_spec from inspect import Parameter, cleandoc from pathlib import Path -from typing import TYPE_CHECKING, TypeGuard +from typing import TYPE_CHECKING if TYPE_CHECKING: from ast import AST - from collections.abc import Iterator, Sequence + from collections.abc import Iterator from inspect import _ParameterKind type FunctionDef_ = AsyncFunctionDef | FunctionDef @@ -70,22 +70,7 @@ def iter_import_names(node: ast.Module | Def) -> Iterator[tuple[str, str]]: yield name, fullname -def get_import_names(node: ast.Module | Def) -> dict[str, str]: - """Return a dictionary of imported names as (name => fullname).""" - return dict(iter_import_names(node)) - - -def _is_assign_name(node: AST) -> TypeGuard[Assign_]: - if isinstance(node, AnnAssign) and isinstance(node.target, Name): - return True - if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): - return True - if isinstance(node, TypeAlias) and isinstance(node.name, Name): - return True - return False - - -def _get_assign_name(node: AST) -> str | None: +def get_assign_name(node: AST) -> str | None: """Return the name of the assign node.""" if isinstance(node, AnnAssign) and isinstance(node.target, Name): return node.target.id @@ -96,30 +81,23 @@ def _get_assign_name(node: AST) -> str | None: return None -def get_name(node: AST) -> str | None: - """Return the node name.""" - if isinstance(node, AsyncFunctionDef | FunctionDef | ClassDef): - return node.name - return _get_assign_name(node) - - -def get_by_name(nodes: Sequence[Def | Assign_], name: str) -> Def | Assign_ | None: - """Return the node that has the name.""" - for node in nodes: - if get_name(node) == name: - return node - return None +def _get_docstring(node: AST) -> str | None: + if not isinstance(node, Expr) or not isinstance(node.value, Constant): + return None + doc = node.value.value + return cleandoc(doc) if isinstance(doc, str) else None def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: """Yield assign nodes.""" assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): - if _is_assign_name(child): + # if _is_assign_name(child): + if get_assign_name(child): if assign_node: yield assign_node child.__doc__ = None - assign_node = child + assign_node = child # type: ignore # noqa: PGH003 else: if assign_node: assign_node.__doc__ = _get_docstring(child) @@ -130,31 +108,6 @@ def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: yield assign_node -def _get_docstring(node: AST) -> str | None: - if not isinstance(node, Expr) or not isinstance(node.value, Constant): - return None - doc = node.value.value - return cleandoc(doc) if isinstance(doc, str) else None - - -def get_assign_nodes(node: ast.Module | ClassDef) -> list[Assign_]: - """Return a list of assign nodes.""" - return list(iter_assign_nodes(node)) - - -def iter_assign_names(node: ast.Module | ClassDef) -> Iterator[tuple[str, str | None]]: - """Yield assign node names.""" - for child in iter_assign_nodes(node): - if name := _get_assign_name(child): - fullname = child.value and ast.unparse(child.value) - yield name, fullname - - -def get_assign_names(node: ast.Module | ClassDef) -> dict[str, str | None]: - """Return a dictionary of assigned names as (name => fullname).""" - return dict(iter_assign_names(node)) - - def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): @@ -162,47 +115,6 @@ def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: yield child -def get_definition_nodes(node: ast.Module | ClassDef) -> list[Def]: - """Return a list of definition nodes.""" - return list(iter_definition_nodes(node)) - - -def iter_definition_names(node: ast.Module | ClassDef) -> Iterator[str]: - """Yield definition node names.""" - for child in iter_definition_nodes(node): - yield child.name - - -def get_definition_names(node: ast.Module | ClassDef) -> list[str]: - """Return a list of definition node names.""" - return list(iter_definition_names(node)) - - -def iter_nodes(node: ast.Module | ClassDef) -> Iterator[Def | Assign_]: - """Yield nodes.""" - yield from iter_assign_nodes(node) - yield from iter_definition_nodes(node) - - -def get_nodes(node: ast.Module | ClassDef) -> list[Def | Assign_]: - """Return a list of nodes.""" - return list(iter_nodes(node)) - - -def iter_names(node: ast.Module | ClassDef) -> Iterator[tuple[str, str]]: - """Yield names as (name, fullname) pairs.""" - yield from iter_import_names(node) - for name, _ in iter_assign_names(node): - yield name, f".{name}" - for name in iter_definition_names(node): - yield name, f".{name}" - - -def get_names(node: ast.Module | ClassDef) -> dict[str, str]: - """Return a dictionary of names as (name => fullname).""" - return dict(iter_names(node)) - - def get_docstring(node: Doc) -> str | None: """Return the docstring for the given node or None if no docstring can be found.""" if isinstance(node, AsyncFunctionDef | FunctionDef | ClassDef | ast.Module): @@ -298,6 +210,7 @@ class Attribute(_Item): annotation: ast.expr | None value: ast.expr | None docstring: str | None + kind: type[Assign_] @dataclass(repr=False) @@ -317,12 +230,12 @@ def get_annotation(node: Assign_) -> ast.expr | None: def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: """Yield assign nodes.""" for assign in iter_assign_nodes(node): - if not (name := get_name(assign)): + if not (name := get_assign_name(assign)): continue annotation = get_annotation(assign) value = None if isinstance(assign, TypeAlias) else assign.value docstring = get_docstring(assign) - yield Attribute(name, annotation, value, docstring) + yield Attribute(name, annotation, value, docstring, type(assign)) def get_attributes(node: ast.Module | ClassDef) -> Attributes: @@ -374,27 +287,22 @@ def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: yield Function(name, docstring, args, node.returns, type(node)) -def get_definitions(module: ast.Module) -> list[Class | Function]: - """Return a list of classes or functions.""" - return list(get_definitions(module)) - - @dataclass class Module: """Module class.""" docstring: str | None + imports: dict[str, str] attrs: Attributes classes: Classes functions: Functions - globals: dict[str, str] # noqa: A003 def get_module(node: ast.Module) -> Module: """Return a [Module] instance.""" docstring = get_docstring(node) + imports = dict(iter_import_names(node)) attrs = get_attributes(node) - globals = get_names(node) # noqa: A001 classes: list[Class] = [] functions: list[Function] = [] for obj in iter_definitions(node): @@ -402,4 +310,4 @@ def get_module(node: ast.Module) -> Module: classes.append(obj) else: functions.append(obj) - return Module(docstring, attrs, Classes(classes), Functions(functions), globals) + return Module(docstring, imports, attrs, Classes(classes), Functions(functions)) diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 377b8c93..3adc7c2c 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -10,10 +10,10 @@ def module(): def test_args(module: Module): - g = module.globals + g = module.attrs for n in g: print(n) - assert 0 + # assert 0 # def test_attrs(module: Module): diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 36e4df6b..b58aff69 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -4,15 +4,10 @@ import pytest from mkapi.ast.node import ( - get_assign_names, - get_assign_nodes, - get_by_name, - get_definition_nodes, - get_docstring, - get_import_names, + get_module, get_module_node, - get_name, - get_names, + iter_definition_nodes, + iter_import_names, iter_import_nodes, ) @@ -44,7 +39,7 @@ def test_iter_import_nodes(module: Module): def test_get_import_names(module: Module): - names = get_import_names(module) + names = dict(iter_import_names(module)) assert "logging" in names assert names["logging"] == "logging" assert "PurePath" in names @@ -53,65 +48,21 @@ def test_get_import_names(module: Module): assert names["urlquote"] == "urllib.parse.quote" -def test_get_by_name(def_nodes): - node = get_by_name(def_nodes, "get_files") - assert isinstance(node, ast.FunctionDef) - assert node.name == "get_files" - node = get_by_name(def_nodes, "InclusionLevel") - assert isinstance(node, ast.ClassDef) - assert get_name(node) == "InclusionLevel" - nodes = get_assign_nodes(node) - node = get_by_name(nodes, "EXCLUDED") - assert isinstance(node, ast.Assign) - assert get_name(node) == "EXCLUDED" - - @pytest.fixture(scope="module") def def_nodes(module: Module): - return get_definition_nodes(module) + return list(iter_definition_nodes(module)) -def test_get_definition_nodes(def_nodes): +def test_iter_definition_nodes(def_nodes): assert any(node.name == "get_files" for node in def_nodes) assert any(node.name == "Files" for node in def_nodes) -def test_get_assign_names(module: Module, def_nodes): - names = get_assign_names(module) - assert names["log"] is not None - assert names["log"].startswith("logging.getLogger") - node = get_by_name(def_nodes, "InclusionLevel") - assert isinstance(node, ast.ClassDef) - names = get_assign_names(node) - assert names["NOT_IN_NAV"] is not None - assert names["NOT_IN_NAV"] == "-1" - - -def test_get_names(module: Module, def_nodes): - names = get_names(module) - assert names["Callable"] == "typing.Callable" - assert names["File"] == ".File" - assert names["get_files"] == ".get_files" - assert names["log"] == ".log" - node = get_by_name(def_nodes, "File") - assert isinstance(node, ast.ClassDef) - names = get_names(node) - assert names["src_path"] == ".src_path" - assert names["url"] == ".url" - - -def test_get_docstring(def_nodes): - node = get_by_name(def_nodes, "get_files") - assert isinstance(node, ast.FunctionDef) - doc = get_docstring(node) - assert isinstance(doc, str) - assert doc.startswith("Walk the `docs_dir`") - with pytest.raises(TypeError): - get_docstring(node.args) # type: ignore - node = get_by_name(def_nodes, "InclusionLevel") - assert isinstance(node, ast.ClassDef) - nodes = get_assign_nodes(node) - node = get_by_name(nodes, "INCLUDED") - doc = get_docstring(node) # type: ignore - assert isinstance(doc, str) - assert doc.startswith("The file is part of the site.") +def test_get_module(): + node = get_module_node("mkapi.ast.node") + module = get_module(node) + assert module.docstring + assert "_ParameterKind" in module.imports + assert module.attrs.Assign_ + assert module.classes.Module + assert module.functions.get_module From 8421ea88545e1db5d14a542195be3f3bad6213e8 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Tue, 2 Jan 2024 07:25:18 +0900 Subject: [PATCH 043/148] type_param --- src/mkapi/ast/node.py | 77 ++++++++++++++++++++++------------- tests/ast/test_defs.py | 15 +++++++ tests/stdlib/__init__.py | 0 tests/stdlib/test_class.py | 45 -------------------- tests/stdlib/test_function.py | 48 ---------------------- 5 files changed, 64 insertions(+), 121 deletions(-) create mode 100644 tests/ast/test_defs.py delete mode 100644 tests/stdlib/__init__.py delete mode 100644 tests/stdlib/test_class.py delete mode 100644 tests/stdlib/test_function.py diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index 4fc54300..7f4b681e 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -70,7 +70,7 @@ def iter_import_names(node: ast.Module | Def) -> Iterator[tuple[str, str]]: yield name, fullname -def get_assign_name(node: AST) -> str | None: +def get_assign_name(node: Assign_) -> str | None: """Return the name of the assign node.""" if isinstance(node, AnnAssign) and isinstance(node.target, Name): return node.target.id @@ -92,12 +92,11 @@ def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: """Yield assign nodes.""" assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): - # if _is_assign_name(child): - if get_assign_name(child): + if isinstance(child, AnnAssign | ast.Assign | TypeAlias): if assign_node: yield assign_node child.__doc__ = None - assign_node = child # type: ignore # noqa: PGH003 + assign_node = child else: if assign_node: assign_node.__doc__ = _get_docstring(child) @@ -198,8 +197,10 @@ def iter_arguments(node: FunctionDef_) -> Iterator[Argument]: yield Argument(arg.arg, arg.annotation, default, kind) -def get_arguments(node: FunctionDef_) -> Arguments: +def get_arguments(node: FunctionDef_ | ClassDef) -> Arguments: """Return the function arguments.""" + if isinstance(node, ClassDef): + return Arguments([]) return Arguments(list(iter_arguments(node))) @@ -211,6 +212,7 @@ class Attribute(_Item): value: ast.expr | None docstring: str | None kind: type[Assign_] + type_params: list[ast.type_param] | None @dataclass(repr=False) @@ -235,7 +237,8 @@ def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: annotation = get_annotation(assign) value = None if isinstance(assign, TypeAlias) else assign.value docstring = get_docstring(assign) - yield Attribute(name, annotation, value, docstring, type(assign)) + type_params = assign.type_params if isinstance(assign, TypeAlias) else None + yield Attribute(name, annotation, value, docstring, type(assign), type_params) def get_attributes(node: ast.Module | ClassDef) -> Attributes: @@ -244,47 +247,65 @@ def get_attributes(node: ast.Module | ClassDef) -> Attributes: @dataclass(repr=False) -class Class(_Item): +class _Def(_Item): + docstring: str | None + args: Arguments + decorators: list[ast.expr] + type_params: list[ast.type_param] + + +@dataclass(repr=False) +class Function(_Def): + """Function class.""" + + returns: ast.expr | None + kind: type[FunctionDef_] + + +@dataclass(repr=False) +class Class(_Def): """Class class.""" bases: list[ast.expr] - docstring: str | None - args: Arguments | None attrs: Attributes +@dataclass(repr=False) +class Functions(_Items[Function]): + """Functions class.""" + + @dataclass(repr=False) class Classes(_Items[Class]): """Classes class.""" -@dataclass(repr=False) -class Function(_Item): - """Function class.""" +def _get_def_args( + node: ClassDef | FunctionDef_, +) -> tuple[str, str | None, Arguments, list[ast.expr], list[ast.type_param]]: + name = node.name + docstring = get_docstring(node) + args = get_arguments(node) + decorators = node.decorator_list + type_params = node.type_params + return name, docstring, args, decorators, type_params - docstring: str | None - args: Arguments - returns: ast.expr | None - kind: type[FunctionDef_] +def get_class(node: ClassDef) -> Class: + """Return a [Class] instance.""" + attrs = get_attributes(node) + return Class(*_get_def_args(node), node.bases, attrs) -@dataclass(repr=False) -class Functions(_Items[Function]): - """Functions class.""" + +def get_function(node: FunctionDef_) -> Function: + """Return a [Function] instance.""" + return Function(*_get_def_args(node), node.returns, type(node)) def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: """Yield classes or functions.""" for node in iter_definition_nodes(module): - name = node.name - docstring = get_docstring(node) - if isinstance(node, ClassDef): - args = Arguments([]) - attrs = get_attributes(node) - yield Class(name, node.bases, docstring, None, attrs) - else: - args = get_arguments(node) - yield Function(name, docstring, args, node.returns, type(node)) + yield get_class(node) if isinstance(node, ClassDef) else get_function(node) @dataclass diff --git a/tests/ast/test_defs.py b/tests/ast/test_defs.py new file mode 100644 index 00000000..f1e51d8d --- /dev/null +++ b/tests/ast/test_defs.py @@ -0,0 +1,15 @@ +import ast + + +def _get_def(src: str): + node = ast.parse(src).body[0] + assert isinstance(node, ast.ClassDef | ast.FunctionDef) + return node + + +def test_deco(): + node = _get_def("@f(x,a=1)\nclass A:\n pass") + assert isinstance(node, ast.ClassDef) + print(node.decorator_list) + node.type_params + assert 0 diff --git a/tests/stdlib/__init__.py b/tests/stdlib/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/stdlib/test_class.py b/tests/stdlib/test_class.py deleted file mode 100644 index 3e6403aa..00000000 --- a/tests/stdlib/test_class.py +++ /dev/null @@ -1,45 +0,0 @@ -import ast -import inspect - - -def test_class(): - src = """ - class C(B): - '''docstring.''' - a:str # attr a. - b:str='c' - '''attr b. - - ade - ''' - c - """ - node = ast.parse(src := inspect.cleandoc(src)).body[0] - assert isinstance(node, ast.ClassDef) - assert node.name == "C" - assert isinstance(bases := node.bases, list) - assert isinstance(bases[0], ast.Name) - assert bases[0].id == "B" - assert isinstance(body := node.body, list) - assert isinstance(body[0], ast.Expr) - assert isinstance(c := body[0].value, ast.Constant) - assert c.value == "docstring." - assert isinstance(a := body[1], ast.AnnAssign) - assert a.target.id == "a" # type: ignore - assert a.value is None - assert a.annotation.id == "str" # type: ignore - assert a.lineno == 3 - assert (a.lineno, a.col_offset, a.end_col_offset) == (3, 4, 9) - line = src.split("\n")[2] - assert line[4:9] == "a:str" - assert line[9:] == " # attr a." - assert isinstance(a := body[2], ast.AnnAssign) - assert a.target.id == "b" # type: ignore - assert a.value.value == "c" # type: ignore - assert isinstance(body[3], ast.Expr) - assert isinstance(c := body[3].value, ast.Constant) - assert inspect.cleandoc(c.value) == "attr b.\n\nade" - assert isinstance(body[4], ast.Expr) - assert isinstance(body[4].value, ast.Name) - assert body[4].value.id == "c" - assert body[4].lineno == 9 diff --git a/tests/stdlib/test_function.py b/tests/stdlib/test_function.py deleted file mode 100644 index addbfa87..00000000 --- a/tests/stdlib/test_function.py +++ /dev/null @@ -1,48 +0,0 @@ -import ast -import inspect - - -def test_function(): - src = """ - def f(a,b:str,/,c:list[int],d:tuple[int,...]="x",*,e=4)->float: - '''docstring.''' - return 1.0 - """ - node = ast.parse(inspect.cleandoc(src)).body[0] - assert isinstance(node, ast.FunctionDef) - assert node.name == "f" - assert isinstance(args := node.args.posonlyargs, list) - assert len(args) == 2 - assert isinstance(args[0], ast.arg) - assert args[0].arg == "a" - assert args[0].annotation is None - assert args[1].arg == "b" - assert isinstance(ann := args[1].annotation, ast.Name) - assert ann.id == "str" - assert isinstance(args := node.args.args, list) - assert len(args) == 2 - assert isinstance(args[0], ast.arg) - assert args[0].arg == "c" - assert isinstance(ann := args[0].annotation, ast.Subscript) - assert isinstance(ann.value, ast.Name) - assert ann.value.id == "list" - assert isinstance(ann.slice, ast.Name) - assert ann.slice.id == "int" - assert args[1].arg == "d" - assert isinstance(ann := args[1].annotation, ast.Subscript) - assert isinstance(ann.value, ast.Name) - assert ann.value.id == "tuple" - assert isinstance(ann.slice, ast.Tuple) - assert isinstance(elts := ann.slice.elts, list) - assert isinstance(elts[0], ast.Name) - assert isinstance(elts[1], ast.Constant) - assert elts[1].value is Ellipsis - assert node.args.vararg is None - assert isinstance(args := node.args.kwonlyargs, list) - assert isinstance(args[0], ast.arg) - assert node.args.kwarg is None - assert node.args.defaults[0].value == "x" # type: ignore - assert node.args.kw_defaults[0].value == 4 # type: ignore - assert isinstance(r := node.returns, ast.Name) - assert r.id == "float" - assert ast.get_docstring(node) == "docstring." From 488245ba0a23377c8de738a34ae285e54e1938b2 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Tue, 2 Jan 2024 12:35:45 +0900 Subject: [PATCH 044/148] iter_identifiers --- src/mkapi/ast/eval.py | 48 ++++++++++++++++++++- src/mkapi/ast/expr.py | 96 ------------------------------------------ src/mkapi/ast/node.py | 21 ++++----- tests/ast/test_defs.py | 16 +++---- tests/ast/test_eval.py | 62 +++++++++++++++++++++++---- tests/ast/test_expr.py | 64 ---------------------------- 6 files changed, 117 insertions(+), 190 deletions(-) delete mode 100644 src/mkapi/ast/expr.py delete mode 100644 tests/ast/test_expr.py diff --git a/src/mkapi/ast/eval.py b/src/mkapi/ast/eval.py index f28fb57e..c4b71b6b 100644 --- a/src/mkapi/ast/eval.py +++ b/src/mkapi/ast/eval.py @@ -1,2 +1,48 @@ -"""Evaluation module.""" +"""Parse expr node.""" from __future__ import annotations + +import ast +import re + +# NAME_PATTERN = re.compile("__mkapi_()__[]") +from collections.abc import Iterator + + +class Transformer(ast.NodeTransformer): # noqa: D101 + def _rename(self, name: str) -> ast.Name: + return ast.Name(id=f"__mkapi__.{name}") + + def visit_Name(self, node: ast.Name) -> ast.Name: # noqa: N802, D102 + return self._rename(node.id) + + def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 + return ast.unparse(self.visit(node)) + + +class StringTransformer(Transformer): # noqa: D101 + def visit_Constant(self, node: ast.Constant) -> ast.Constant | ast.Name: # noqa: N802, D102 + if isinstance(node.value, str): + return self._rename(node.value) + return node + + +def iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: + """Yield identifiers as a tuple of (code, is_identifier).""" + start = 0 + while start < len(source): + index = source.find("__mkapi__.", start) + if index == -1: + yield source[start:], False + return + else: + if index != 0: + yield source[start:index], False + start = end = index + 10 # 10 == len("__mkapi__.") + while end < len(source): + s = source[end] + if s == "." or s.isdigit() or s.isidentifier(): + end += 1 + else: + break + yield source[start:end], True + start = end diff --git a/src/mkapi/ast/expr.py b/src/mkapi/ast/expr.py deleted file mode 100644 index 15139ca3..00000000 --- a/src/mkapi/ast/expr.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Parse expr node.""" -from __future__ import annotations - -import ast -from ast import Attribute, Call, Constant, List, Name, Slice, Starred, Subscript, Tuple -from collections.abc import Callable - -type Callback = Callable[[ast.expr], str | ast.expr] # Python 3.12 - - -def _parse_attribute(node: Attribute, callback: Callback) -> str: - return ".".join([parse_expr(node.value, callback), node.attr]) - - -def _parse_subscript(node: Subscript, callback: Callback) -> str: - value = parse_expr(node.value, callback) - if isinstance(node.slice, Slice): - slice_ = _parse_slice(node.slice, callback) - elif isinstance(node.slice, Tuple): - slice_ = _parse_elts(node.slice.elts, callback) - else: - slice_ = parse_expr(node.slice, callback) - return f"{value}[{slice_}]" - - -def _parse_slice(node: Slice, callback: Callback) -> str: - lower = parse_expr(node.lower, callback) if node.lower else "" - upper = parse_expr(node.upper, callback) if node.upper else "" - step = ":" + parse_expr(node.step, callback) if node.step else "" - return f"{lower}:{upper}{step}" - - -def _parse_starred(node: Starred, callback: Callback) -> str: - return "*" + parse_expr(node.value, callback) - - -def _parse_call(node: Call, callback: Callback) -> str: - func = parse_expr(node.func, callback) - args = ", ".join(parse_expr(arg, callback) for arg in node.args) - it = [_parse_keyword(keyword, callback) for keyword in node.keywords] - keywords = (", " + ", ".join(it)) if it else "" - return f"{func}({args}{keywords})" - - -def _parse_keyword(node: ast.keyword, callback: Callback) -> str: - value = parse_expr(node.value, callback) - return f"**{value}" if node.arg is None else f"{node.arg}={value}" - - -def _parse_elts(nodes: list[ast.expr], callback: Callback) -> str: - return ", ".join(parse_expr(node, callback) for node in nodes) - - -def _parse_list(node: List, callback: Callback) -> str: - return f"[{_parse_elts(node.elts,callback)}]" - - -def _parse_tuple(node: Tuple, callback: Callback) -> str: - elts = _parse_elts(node.elts, callback) - if len(node.elts) == 1: - elts = elts + "," - return f"({elts})" - - -def _parse_name(node: Name, _: Callback) -> str: - return node.id - - -def _parse_constant(node: Constant, _: Callback) -> str: - if node.value is Ellipsis: - return "..." - if isinstance(node.value, str): - return f"{node.value!r}" - return str(node.value) - - -PARSE_EXPR_FUNCTIONS: list[tuple[type, Callable[..., str]]] = [ - (Attribute, _parse_attribute), - (Subscript, _parse_subscript), - (Constant, _parse_constant), - (Starred, _parse_starred), - (Call, _parse_call), - (List, _parse_list), - (Tuple, _parse_tuple), - (Name, _parse_name), -] - - -def parse_expr(expr: ast.expr, callback: Callback = None) -> str: - """Return the string expression for an expr.""" - if callback and isinstance(expr_str := callback(expr), str): - return expr_str - for type_, parse in PARSE_EXPR_FUNCTIONS: - if isinstance(expr, type_): - return parse(expr, callback) - return ast.unparse(expr) diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast/node.py index 7f4b681e..72f0ba1e 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast/node.py @@ -160,6 +160,9 @@ def __repr__(self) -> str: class _Items[T]: items: list[T] + def __getitem__(self, index: int) -> T: + return self.items[index] + def __getattr__(self, name: str) -> T: names = [elem.name for elem in self.items] # type: ignore # noqa: PGH003 return self.items[names.index(name)] @@ -291,21 +294,15 @@ def _get_def_args( return name, docstring, args, decorators, type_params -def get_class(node: ClassDef) -> Class: - """Return a [Class] instance.""" - attrs = get_attributes(node) - return Class(*_get_def_args(node), node.bases, attrs) - - -def get_function(node: FunctionDef_) -> Function: - """Return a [Function] instance.""" - return Function(*_get_def_args(node), node.returns, type(node)) - - def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: """Yield classes or functions.""" for node in iter_definition_nodes(module): - yield get_class(node) if isinstance(node, ClassDef) else get_function(node) + args = _get_def_args(node) + if isinstance(node, ClassDef): + attrs = get_attributes(node) + yield Class(*args, node.bases, attrs) + else: + yield Function(*args, node.returns, type(node)) @dataclass diff --git a/tests/ast/test_defs.py b/tests/ast/test_defs.py index f1e51d8d..8a84cfc2 100644 --- a/tests/ast/test_defs.py +++ b/tests/ast/test_defs.py @@ -1,15 +1,13 @@ import ast +from mkapi.ast.node import get_module -def _get_def(src: str): - node = ast.parse(src).body[0] - assert isinstance(node, ast.ClassDef | ast.FunctionDef) - return node + +def _get(src: str): + return get_module(ast.parse(src)) def test_deco(): - node = _get_def("@f(x,a=1)\nclass A:\n pass") - assert isinstance(node, ast.ClassDef) - print(node.decorator_list) - node.type_params - assert 0 + module = _get("@f(x,a=1)\nclass A:\n pass") + deco = module.classes.A.decorators[0] + assert isinstance(deco, ast.Call) diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 3adc7c2c..18f92244 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -1,22 +1,68 @@ +import ast + import pytest +from mkapi.ast.eval import StringTransformer, iter_identifiers from mkapi.ast.node import Module, get_module, get_module_node +def _unparse(src: str) -> str: + expr = ast.parse(src).body[0] + assert isinstance(expr, ast.Expr) + return StringTransformer().unparse(expr.value) + + +def test_parse_expr_name(): + assert _unparse("a") == "__mkapi__.a" + + +def test_parse_expr_subscript(): + assert _unparse("a[b]") == "__mkapi__.a[__mkapi__.b]" + + +def test_parse_expr_attribute(): + assert _unparse("a.b") == "__mkapi__.a.b" + assert _unparse("a.b.c") == "__mkapi__.a.b.c" + assert _unparse("a().b[0].c()") == "__mkapi__.a().b[0].c()" + assert _unparse("a(b.c[d])") == "__mkapi__.a(__mkapi__.b.c[__mkapi__.d])" + + +def test_parse_expr_str(): + assert _unparse("list['X.Y']") == "__mkapi__.list[__mkapi__.X.Y]" + + @pytest.fixture(scope="module") def module(): node = get_module_node("mkapi.ast.node") return get_module(node) -def test_args(module: Module): - g = module.attrs - for n in g: - print(n) - # assert 0 +def test_iter_identifiers(): + x = list(iter_identifiers("x, __mkapi__.a.b0[__mkapi__.c], y")) + assert len(x) == 5 + assert x[0] == ("x, ", False) + assert x[1] == ("a.b0", True) + assert x[2] == ("[", False) + assert x[3] == ("c", True) + assert x[4] == ("], y", False) + x = list(iter_identifiers("__mkapi__.a.b()")) + assert len(x) == 2 + assert x[0] == ("a.b", True) + assert x[1] == ("()", False) + x = list(iter_identifiers("'ab'\n __mkapi__.a")) + assert len(x) == 2 + assert x[0] == ("'ab'\n ", False) + assert x[1] == ("a", True) + x = list(iter_identifiers("'ab'\n __mkapi__.α.β.γ")) # noqa: RUF001 + assert len(x) == 2 + assert x[0] == ("'ab'\n ", False) + assert x[1] == ("α.β.γ", True) # noqa: RUF001 -# def test_attrs(module: Module): -# attrs = module.attrs -# print(attrs) +# def test_functions(module: Module): +# func = module.functions._get_def_args # noqa: SLF001 +# ann = func.args[0].annotation +# assert isinstance(ann, ast.expr) +# print(Transformer().unparse(ann)) +# print(Transformer().unparse(func.returns)) # assert 0 diff --git a/tests/ast/test_expr.py b/tests/ast/test_expr.py deleted file mode 100644 index 5869a4d6..00000000 --- a/tests/ast/test_expr.py +++ /dev/null @@ -1,64 +0,0 @@ -import ast - -import pytest - -from mkapi.ast.expr import parse_expr - - -def _expr(src: str) -> str: - expr = ast.parse(src).body[0] - assert isinstance(expr, ast.Expr) - return parse_expr(expr.value) - - -@pytest.mark.parametrize("x", ["1", "'a'", "...", "None", "True"]) -def test_parse_expr_constant(x): - assert _expr(x) == x - - -@pytest.mark.parametrize("x", ["()", "(1,)", "(1, 2)"]) -def test_parse_expr_tuple(x): - assert _expr(x.replace(" ", "")) == x - - -@pytest.mark.parametrize("x", ["[]", "[1]", "[1, 2]"]) -def test_parse_expr_list(x): - assert _expr(x.replace(" ", "")) == x - - -def test_parse_expr_attribute(): - assert _expr("a.b.c") == "a.b.c" - - -@pytest.mark.parametrize("x", ["1:2", "1:", ":-2", "1:10:2", "1::2", "::3", ":3:2"]) -def test_parse_expr_slice(x): - x = f"a[{x}]" - assert _expr(x) == x - - -def test_parse_expr_subscript(): - assert _expr("a[1]") == "a[1]" - assert _expr("a[1,2.0,'c']") == "a[1, 2.0, 'c']" - - -def test_parse_expr_call(): - assert _expr("f()") == "f()" - assert _expr("f(a)") == "f(a)" - assert _expr("f(a,b=c(),*d,**e[1])") == "f(a, *d, b=c(), **e[1])" - - -def test_callback(): - def callback(expr: ast.expr): - if isinstance(expr, ast.Name): - return f"<{expr.id}>" - return None - - expr = ast.parse("a[b]").body[0] - assert isinstance(expr, ast.Expr) - assert parse_expr(expr.value, callback) == "[]" - expr = ast.parse("a.b.c").body[0] - assert isinstance(expr, ast.Expr) - assert parse_expr(expr.value, callback) == ".b.c" - expr = ast.parse("a(1).b[c].d").body[0] - assert isinstance(expr, ast.Expr) - assert parse_expr(expr.value, callback) == "(1).b[].d" From e18ef4eec7d5b62158ff1a3ff8ac1bc3a6eeb665 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Tue, 2 Jan 2024 17:17:33 +0900 Subject: [PATCH 045/148] themes templates --- examples/styles/google.py | 3 + src/mkapi/{ast/node.py => ast.py} | 104 +++-- src/mkapi/ast/__init__.py | 0 src/mkapi/ast/eval.py | 48 --- .../core/base.py => src/mkapi/docstring.py | 404 +++++++++++++++--- src/mkapi/inspect.py | 11 - src/mkapi/objects.py | 32 -- src/mkapi/plugins.py | 3 +- {src_old => src}/mkapi/templates/bases.jinja2 | 0 {src_old => src}/mkapi/templates/code.jinja2 | 0 .../mkapi/templates/docstring.jinja2 | 0 {src_old => src}/mkapi/templates/items.jinja2 | 0 .../mkapi/templates/macros.jinja2 | 0 .../mkapi/templates/member.jinja2 | 0 .../mkapi/templates/module.jinja2 | 0 {src_old => src}/mkapi/templates/node.jinja2 | 0 .../mkapi/templates/object.jinja2 | 0 .../mkapi/themes}/css/mkapi-common.css | 0 .../mkapi/themes}/css/mkapi-ivory.css | 0 .../mkapi/themes}/css/mkapi-mkdocs.css | 0 .../mkapi/themes}/css/mkapi-readthedocs.css | 0 .../theme => src/mkapi/themes}/js/mkapi.js | 0 .../theme => src/mkapi/themes}/mkapi.yml | 0 src/mkapi/utils.py | 119 ++++++ src_old/mkapi/__about__.py | 3 - src_old/mkapi/__init__.py | 7 - src_old/mkapi/core/docstring.py | 291 ------------- src_old/mkapi/core/filter.py | 44 -- src_old/mkapi/core/preprocess.py | 121 ------ src_old/mkapi/core/structure.py | 22 +- src_old/mkapi/inspect/__init__.py | 1 - src_old/mkapi/inspect/attribute.py | 259 ----------- src_old/mkapi/inspect/signature.py | 142 ------ src_old/mkapi/inspect/typing.py | 200 --------- src_old/mkapi/main.py | 20 - src_old/mkapi/plugins/__init__.py | 0 src_old/mkapi/plugins/mkdocs.py | 305 ------------- tests/ast/test_args.py | 2 +- tests/ast/test_attrs.py | 2 +- tests/ast/test_defs.py | 2 +- tests/ast/test_eval.py | 29 +- tests/ast/test_node.py | 6 +- .../test_docstring.py | 76 +++- tests/test_inspect.py | 12 - tests/test_parser.py | 2 - tests/test_utils.py | 45 +- tests_old/__init__.py | 0 tests_old/conftest.py | 28 -- tests_old/core/test_core_base.py | 47 -- tests_old/core/test_core_docstring.py | 25 -- tests_old/core/test_core_preprocess.py | 43 -- tests_old/inspect/test_inspect_attribute.py | 157 ------- tests_old/inspect/test_inspect_signature.py | 69 --- .../test_inspect_signature_typecheck.py | 13 - tests_old/inspect/test_inspect_typing.py | 14 - tests_old/plugins/test_plugins_mkdocs.py | 83 ---- 56 files changed, 696 insertions(+), 2098 deletions(-) rename src/mkapi/{ast/node.py => ast.py} (82%) delete mode 100644 src/mkapi/ast/__init__.py delete mode 100644 src/mkapi/ast/eval.py rename src_old/mkapi/core/base.py => src/mkapi/docstring.py (57%) delete mode 100644 src/mkapi/inspect.py delete mode 100644 src/mkapi/objects.py rename {src_old => src}/mkapi/templates/bases.jinja2 (100%) rename {src_old => src}/mkapi/templates/code.jinja2 (100%) rename {src_old => src}/mkapi/templates/docstring.jinja2 (100%) rename {src_old => src}/mkapi/templates/items.jinja2 (100%) rename {src_old => src}/mkapi/templates/macros.jinja2 (100%) rename {src_old => src}/mkapi/templates/member.jinja2 (100%) rename {src_old => src}/mkapi/templates/module.jinja2 (100%) rename {src_old => src}/mkapi/templates/node.jinja2 (100%) rename {src_old => src}/mkapi/templates/object.jinja2 (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/css/mkapi-common.css (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/css/mkapi-ivory.css (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/css/mkapi-mkdocs.css (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/css/mkapi-readthedocs.css (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/js/mkapi.js (100%) rename {src_old/mkapi/theme => src/mkapi/themes}/mkapi.yml (100%) delete mode 100644 src_old/mkapi/__about__.py delete mode 100644 src_old/mkapi/__init__.py delete mode 100644 src_old/mkapi/core/docstring.py delete mode 100644 src_old/mkapi/core/filter.py delete mode 100644 src_old/mkapi/core/preprocess.py delete mode 100644 src_old/mkapi/inspect/__init__.py delete mode 100644 src_old/mkapi/inspect/attribute.py delete mode 100644 src_old/mkapi/inspect/signature.py delete mode 100644 src_old/mkapi/inspect/typing.py delete mode 100644 src_old/mkapi/main.py delete mode 100644 src_old/mkapi/plugins/__init__.py delete mode 100644 src_old/mkapi/plugins/mkdocs.py rename tests_old/core/test_core_docstring_from_text.py => tests/test_docstring.py (59%) delete mode 100644 tests/test_inspect.py delete mode 100644 tests/test_parser.py delete mode 100644 tests_old/__init__.py delete mode 100644 tests_old/conftest.py delete mode 100644 tests_old/core/test_core_base.py delete mode 100644 tests_old/core/test_core_docstring.py delete mode 100644 tests_old/core/test_core_preprocess.py delete mode 100644 tests_old/inspect/test_inspect_attribute.py delete mode 100644 tests_old/inspect/test_inspect_signature.py delete mode 100644 tests_old/inspect/test_inspect_signature_typecheck.py delete mode 100644 tests_old/inspect/test_inspect_typing.py delete mode 100644 tests_old/plugins/test_plugins_mkdocs.py diff --git a/examples/styles/google.py b/examples/styles/google.py index 2d5af331..62e2bac8 100644 --- a/examples/styles/google.py +++ b/examples/styles/google.py @@ -63,6 +63,9 @@ class ExampleClass: ValueError: If the length of `x` is equal to 0. """ + e: str + """dde""" + def __init__(self, x: list[int], y: tuple[str, int]): if len(x) == 0 or y[1] == 0: raise ValueError diff --git a/src/mkapi/ast/node.py b/src/mkapi/ast.py similarity index 82% rename from src/mkapi/ast/node.py rename to src/mkapi/ast.py index 72f0ba1e..a19a6cb4 100644 --- a/src/mkapi/ast/node.py +++ b/src/mkapi/ast.py @@ -124,30 +124,6 @@ def get_docstring(node: Doc) -> str | None: raise TypeError(msg) -ARGS_KIND: dict[_ParameterKind, str] = { - Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list - Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list - Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None - Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list - Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None -} - - -def _iter_arguments(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: - for kind, attr in ARGS_KIND.items(): - if args := getattr(node.args, attr): - it = args if isinstance(args, list) else [args] - yield from ((arg, kind) for arg in it) - - -def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: - args = node.args - num_positional = len(args.posonlyargs) + len(args.args) - nones = [None] * num_positional - yield from [*nones, *args.defaults][-num_positional:] - yield from args.kw_defaults - - @dataclass class _Item: name: str @@ -160,8 +136,10 @@ def __repr__(self) -> str: class _Items[T]: items: list[T] - def __getitem__(self, index: int) -> T: - return self.items[index] + def __getitem__(self, index: int | str) -> T: + if isinstance(index, int): + return self.items[index] + return getattr(self, index) def __getattr__(self, name: str) -> T: names = [elem.name for elem in self.items] # type: ignore # noqa: PGH003 @@ -175,6 +153,30 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({names})" +ARGS_KIND: dict[_ParameterKind, str] = { + Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list + Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list + Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None + Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list + Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None +} + + +def _iter_arguments(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: + for kind, attr in ARGS_KIND.items(): + if args := getattr(node.args, attr): + it = args if isinstance(args, list) else [args] + yield from ((arg, kind) for arg in it) + + +def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: + args = node.args + num_positional = len(args.posonlyargs) + len(args.args) + nones = [None] * num_positional + yield from [*nones, *args.defaults][-num_positional:] + yield from args.kw_defaults + + @dataclass(repr=False) class Argument(_Item): """Argument class.""" @@ -252,7 +254,7 @@ def get_attributes(node: ast.Module | ClassDef) -> Attributes: @dataclass(repr=False) class _Def(_Item): docstring: str | None - args: Arguments + arguments: Arguments decorators: list[ast.expr] type_params: list[ast.type_param] @@ -270,7 +272,7 @@ class Class(_Def): """Class class.""" bases: list[ast.expr] - attrs: Attributes + attributes: Attributes @dataclass(repr=False) @@ -288,10 +290,10 @@ def _get_def_args( ) -> tuple[str, str | None, Arguments, list[ast.expr], list[ast.type_param]]: name = node.name docstring = get_docstring(node) - args = get_arguments(node) + arguments = get_arguments(node) decorators = node.decorator_list type_params = node.type_params - return name, docstring, args, decorators, type_params + return name, docstring, arguments, decorators, type_params def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: @@ -311,7 +313,7 @@ class Module: docstring: str | None imports: dict[str, str] - attrs: Attributes + attributes: Attributes classes: Classes functions: Functions @@ -329,3 +331,43 @@ def get_module(node: ast.Module) -> Module: else: functions.append(obj) return Module(docstring, imports, attrs, Classes(classes), Functions(functions)) + + +class Transformer(ast.NodeTransformer): # noqa: D101 + def _rename(self, name: str) -> ast.Name: + return ast.Name(id=f"__mkapi__.{name}") + + def visit_Name(self, node: ast.Name) -> ast.Name: # noqa: N802, D102 + return self._rename(node.id) + + def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 + return ast.unparse(self.visit(node)) + + +class StringTransformer(Transformer): # noqa: D101 + def visit_Constant(self, node: ast.Constant) -> ast.Constant | ast.Name: # noqa: N802, D102 + if isinstance(node.value, str): + return self._rename(node.value) + return node + + +def iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: + """Yield identifiers as a tuple of (code, is_identifier).""" + start = 0 + while start < len(source): + index = source.find("__mkapi__.", start) + if index == -1: + yield source[start:], False + return + else: + if index != 0: + yield source[start:index], False + start = end = index + 10 # 10 == len("__mkapi__.") + while end < len(source): + s = source[end] + if s == "." or s.isdigit() or s.isidentifier(): + end += 1 + else: + break + yield source[start:end], True + start = end diff --git a/src/mkapi/ast/__init__.py b/src/mkapi/ast/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/mkapi/ast/eval.py b/src/mkapi/ast/eval.py deleted file mode 100644 index c4b71b6b..00000000 --- a/src/mkapi/ast/eval.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Parse expr node.""" -from __future__ import annotations - -import ast -import re - -# NAME_PATTERN = re.compile("__mkapi_()__[]") -from collections.abc import Iterator - - -class Transformer(ast.NodeTransformer): # noqa: D101 - def _rename(self, name: str) -> ast.Name: - return ast.Name(id=f"__mkapi__.{name}") - - def visit_Name(self, node: ast.Name) -> ast.Name: # noqa: N802, D102 - return self._rename(node.id) - - def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 - return ast.unparse(self.visit(node)) - - -class StringTransformer(Transformer): # noqa: D101 - def visit_Constant(self, node: ast.Constant) -> ast.Constant | ast.Name: # noqa: N802, D102 - if isinstance(node.value, str): - return self._rename(node.value) - return node - - -def iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: - """Yield identifiers as a tuple of (code, is_identifier).""" - start = 0 - while start < len(source): - index = source.find("__mkapi__.", start) - if index == -1: - yield source[start:], False - return - else: - if index != 0: - yield source[start:index], False - start = end = index + 10 # 10 == len("__mkapi__.") - while end < len(source): - s = source[end] - if s == "." or s.isdigit() or s.isidentifier(): - end += 1 - else: - break - yield source[start:end], True - start = end diff --git a/src_old/mkapi/core/base.py b/src/mkapi/docstring.py similarity index 57% rename from src_old/mkapi/core/base.py rename to src/mkapi/docstring.py index 60f6da04..b91eedcf 100644 --- a/src_old/mkapi/core/base.py +++ b/src/mkapi/docstring.py @@ -1,9 +1,44 @@ -"""A module provides entity classes to represent docstring structure.""" -from collections.abc import Callable, Iterator -from dataclasses import dataclass, field +"""Docstring module.""" +from __future__ import annotations -from mkapi.core.link import LINK_PATTERN -from mkapi.core.preprocess import add_fence, delete_ptags +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, Self + +from mkapi.utils import ( + add_admonition, + add_fence, + delete_ptags, + get_indent, + join_without_indent, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +LINK_PATTERN = re.compile(r"\[(.+?)\]\[(.+?)\]") + +SECTION_NAMES = [ + "Args", + "Arguments", + "Attributes", + "Example", + "Examples", + "Note", + "Notes", + "Parameters", + "Raises", + "Returns", + "References", + "See Also", + "Todo", + "Warning", + "Warnings", + "Warns", + "Yield", + "Yields", +] +type Style = Literal["google", "numpy", ""] @dataclass @@ -25,12 +60,10 @@ class Base: [] """ - name: str = "" #: Name of self. - markdown: str = "" #: Markdown source. - html: str = field(default="", init=False) #: HTML output after conversion. - # callback: Callable[["Base"], str] | None = field(default=None, init=False) - callback: Callable[..., str] | None = field(default=None, init=False) - """Callback function to modify HTML output.""" + name: str = "" + markdown: str = "" + html: str = field(default="", init=False) + callback: Callable[[Self], str] | None = field(default=None, init=False) def __repr__(self) -> str: class_name = self.__class__.__name__ @@ -40,23 +73,19 @@ def __bool__(self) -> bool: """Return True if name is not empty.""" return bool(self.name) - def __iter__(self) -> Iterator["Base"]: + def __iter__(self) -> Iterator[Self]: """Yield self if markdown is not empty.""" if self.markdown: yield self def set_html(self, html: str) -> None: - """Set HTML output. - - Args: - html: HTML output. - """ + """Set HTML.""" self.html = html if self.callback: self.html = self.callback(self) - def copy(self): # noqa: ANN201 - """Return a copy of the {class} instance.""" + def copy(self) -> Self: + """Return a copy of the instance.""" return self.__class__(name=self.name, markdown=self.markdown) @@ -94,13 +123,13 @@ def set_html(self, html: str) -> None: html = delete_ptags(html) super().set_html(html) - def copy(self): # noqa: ANN201, D102 + def copy(self) -> Self: # noqa: D102 return self.__class__(name=self.name) @dataclass(repr=False) class Type(Inline): - """Type of Item_, Section_, Docstring_, or [Object](mkapi.core.structure.Object). + """Type of [Item], [Section], [Docstring], or [Object]. Examples: >>> a = Type("str") @@ -108,11 +137,11 @@ class Type(Inline): Type('str') >>> list(a) [] - >>> b = Type("[Object](base.Object)") + >>> b = Type("[Base][docstring.Base]") >>> b.markdown - '[Object](base.Object)' + '[Base][docstring.Base]' >>> list(b) - [Type('[Object](base.Object)')] + [Type('[Base][docstring.Base]')] >>> a.copy() Type('str') """ @@ -137,16 +166,16 @@ class Item(Type): as a class attribute in HTML. Examples: - >>> item = Item('[x](x)', Type('int'), Inline('A parameter.')) + >>> item = Item('[x][x]', Type('int'), Inline('A parameter.')) >>> item - Item('[x](x)', 'int') + Item('[x][x]', 'int') >>> item.name, item.markdown, item.html - ('[x](x)', '[x](x)', '') + ('[x][x]', '[x][x]', '') >>> item.type Type('int') >>> item.description Inline('A parameter.') - >>> item = Item('[x](x)', 'str', 'A parameter.') + >>> item = Item('[x][x]', 'str', 'A parameter.') >>> item.type Type('str') >>> it = iter(item) @@ -190,9 +219,9 @@ def to_tuple(self) -> tuple[str, str, str]: """Return a tuple of (name, type, description). Examples: - >>> item = Item("[x](x)", "int", "A parameter.") + >>> item = Item("[x][x]", "int", "A parameter.") >>> item.to_tuple() - ('[x](x)', 'int', 'A parameter.') + ('[x][x]', 'int', 'A parameter.') """ return self.name, self.type.name, self.description.name @@ -205,7 +234,7 @@ def set_type(self, type_: Type, *, force: bool = False) -> None: description. See Also: - * Item.update_ + * [Item.update] """ if not force and self.type.name: return @@ -221,14 +250,14 @@ def set_description(self, description: Inline, *, force: bool = False) -> None: description. See Also: - * Item.update_ + * [Item.update] """ if not force and self.description.name: return if description.name: self.description = description.copy() - def update(self, item: "Item", *, force: bool = False) -> None: + def update(self, item: Item, *, force: bool = False) -> None: """Update type and description. Args: @@ -259,9 +288,9 @@ def update(self, item: "Item", *, force: bool = False) -> None: self.set_description(item.description, force=force) self.set_type(item.type, force=force) - def copy(self): # noqa: ANN201, D102 + def copy(self) -> Self: # noqa: D102 name, type_, desc = self.to_tuple() - return Item(name, Type(type_), Inline(desc), kind=self.kind) + return self.__class__(name, Type(type_), Inline(desc), kind=self.kind) @dataclass @@ -273,12 +302,12 @@ class Section(Base): type: Type of self. Examples: - >>> items = [Item("x"), Item("[y](a)"), Item("z")] + >>> items = [Item("x"), Item("[y][a]"), Item("z")] >>> section = Section("Parameters", items=items) >>> section Section('Parameters', num_items=3) >>> list(section) - [Item('[y](a)', '')] + [Item('[y][a]', '')] """ items: list[Item] = field(default_factory=list) @@ -342,7 +371,7 @@ def __delitem__(self, name: str) -> None: raise KeyError(msg) def __contains__(self, name: str) -> bool: - """Return True if there is an [Item]() instance whose name is equal to `name`. + """Return True if there is an [Item] instance whose name is equal to `name`. Args: name: Item name. @@ -350,7 +379,7 @@ def __contains__(self, name: str) -> bool: return any(item.name == name for item in self.items) def set_item(self, item: Item, *, force: bool = False) -> None: - """Set an [Item](). + """Set an [Item]. Args: item: Item instance. @@ -378,7 +407,7 @@ def set_item(self, item: Item, *, force: bool = False) -> None: return self.items.append(item.copy()) - def update(self, section: "Section", *, force: bool = False) -> None: + def update(self, section: Section, *, force: bool = False) -> None: """Update items. Args: @@ -402,7 +431,7 @@ def update(self, section: "Section", *, force: bool = False) -> None: for item in section.items: self.set_item(item, force=force) - def merge(self, section: "Section", *, force: bool = False) -> "Section": + def merge(self, section: Section, *, force: bool = False) -> Section: """Return a merged Section. Examples: @@ -428,8 +457,8 @@ def merge(self, section: "Section", *, force: bool = False) -> "Section": merged.set_item(item, force=force) return merged - def copy(self): # noqa: ANN201 - """Return a copy of the {class} instace. + def copy(self) -> Self: + """Return a copy of the instace. Examples: >>> s = Section("E", "markdown", [Item("a", "s"), Item("b", "i")]) @@ -448,8 +477,8 @@ class Docstring: """Docstring of an object. Args: - sections: List of Section instance. - type: Type for Returns or Yields sections. + sections: List of [Section] instance. + type: [Type] for Returns or Yields sections. Examples: Empty docstring: @@ -458,7 +487,7 @@ class Docstring: Docstring with 3 sections: >>> default = Section("", markdown="Default") - >>> parameters = Section("Parameters", items=[Item("a"), Item("[b](!a)")]) + >>> parameters = Section("Parameters", items=[Item("a"), Item("[b][!a]")]) >>> returns = Section("Returns", markdown="Results") >>> docstring = Docstring([default, parameters, returns]) >>> docstring @@ -466,7 +495,7 @@ class Docstring: `Docstring` is iterable: >>> list(docstring) - [Section('', num_items=0), Item('[b](!a)', ''), Section('Returns', num_items=0)] + [Section('', num_items=0), Item('[b][!a]', ''), Section('Returns', num_items=0)] Indexing: >>> docstring["Parameters"].items[0].name @@ -530,7 +559,7 @@ def set_section( copy: bool = False, replace: bool = False, ) -> None: - """Set a [Section](). + """Set a [Section]. Args: section: Section instance. @@ -581,3 +610,282 @@ def set_section( self.sections.insert(k, section) return self.sections.append(section) + + +def _rename_section(name: str) -> str: + if name in ["Args", "Arguments"]: + return "Parameters" + if name == "Warns": + return "Warnings" + return name + + +def section_heading(line: str) -> tuple[str, Style]: + """Return a tuple of (section name, style name). + + Args: + line: Docstring line. + + Examples: + >>> section_heading("Args:") + ('Args', 'google') + >>> section_heading("Raises") + ('Raises', 'numpy') + >>> section_heading("other") + ('', '') + """ + if line in SECTION_NAMES: + return line, "numpy" + if line.endswith(":") and line[:-1] in SECTION_NAMES: + return line[:-1], "google" + return "", "" + + +def split_section(doc: str) -> Iterator[tuple[str, str, str]]: + r"""Yield a tuple of (section name, contents, style). + + Args: + doc: Docstring + + Examples: + >>> doc = "abc\n\nArgs:\n x: X\n" + >>> it = split_section(doc) + >>> next(it) + ('', 'abc', '') + >>> next(it) + ('Parameters', 'x: X', 'google') + """ + name = style = "" + start = indent = 0 + lines = [x.rstrip() for x in doc.split("\n")] + for stop, line in enumerate(lines, 1): + next_indent = -1 if stop == len(lines) else get_indent(lines[stop]) + if not line and next_indent < indent and name: + if start < stop - 1: + yield name, join_without_indent(lines[start : stop - 1]), style + start, name = stop, "" + else: + section, style_ = section_heading(line) + if section: + if start < stop - 1: + yield name, join_without_indent(lines[start : stop - 1]), style + style, start, name = style_, stop, _rename_section(section) + if style == "numpy": # skip underline without counting the length. + start += 1 + indent = next_indent + if start < len(lines): + yield name, join_without_indent(lines[start:]), style + + +def split_parameter(doc: str) -> Iterator[list[str]]: + """Yield a list of parameter string. + + Args: + doc: Docstring + """ + start = stop = 0 + lines = [x.rstrip() for x in doc.split("\n")] + for stop, _ in enumerate(lines, 1): + next_indent = 0 if stop == len(lines) else get_indent(lines[stop]) + if next_indent == 0: + yield lines[start:stop] + start = stop + + +PARAMETER_PATTERN = { + "google": re.compile(r"(.*?)\s*?\((.*?)\)"), + "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), +} + + +def parse_parameter(lines: list[str], style: str) -> Item: + """Return a [Item] instance corresponding to a parameter. + + Args: + lines: Splitted parameter docstring lines. + style: Docstring style. `google` or `numpy`. + """ + if style == "google": + name, _, line = lines[0].partition(":") + name, parsed = name.strip(), [line.strip()] + else: + name, parsed = lines[0].strip(), [] + if len(lines) > 1: + indent = get_indent(lines[1]) + for line in lines[1:]: + parsed.append(line[indent:]) + if m := re.match(PARAMETER_PATTERN[style], name): + name, type_ = m.group(1), m.group(2) + else: + type_ = "" + return Item(name, Type(type_), Inline("\n".join(parsed))) + + +def parse_parameters(doc: str, style: str) -> list[Item]: + """Return a list of Item.""" + return [parse_parameter(lines, style) for lines in split_parameter(doc)] + + +def parse_returns(doc: str, style: str) -> tuple[str, str]: + """Return a tuple of (type, markdown).""" + type_, lines = "", doc.split("\n") + if style == "google": + if ":" in lines[0]: + type_, _, lines[0] = lines[0].partition(":") + type_ = type_.strip() + lines[0] = lines[0].strip() + else: + type_, lines = lines[0].strip(), lines[1:] + return type_, join_without_indent(lines) + + +def parse_section(name: str, doc: str, style: str) -> Section: + """Return a [Section] instance.""" + type_ = markdown = "" + items = [] + if name in ["Parameters", "Attributes", "Raises"]: + items = parse_parameters(doc, style) + elif name in ["Returns", "Yields"]: + type_, markdown = parse_returns(doc, style) + else: + markdown = doc + return Section(name, markdown, items, Type(type_)) + + +def postprocess_sections(sections: list[Section]) -> None: # noqa: D103 + for section in sections: + if section.name in ["Note", "Notes", "Warning", "Warnings"]: + markdown = add_admonition(section.name, section.markdown) + if sections and sections[-1].name == "": + sections[-1].markdown += "\n\n" + markdown + continue + section.name = "" + section.markdown = markdown + + +def parse_docstring(doc: str) -> Docstring: + """Return a [Docstring]) instance.""" + if not doc: + return Docstring() + sections = [parse_section(*section_args) for section_args in split_section(doc)] + postprocess_sections(sections) + return Docstring(sections) + + +# def parse_bases(doc: Docstring, obj: object) -> None: +# """Parse base classes to create a Base(s) line.""" +# if not inspect.isclass(obj) or not hasattr(obj, "mro"): +# return +# objs = get_mro(obj)[1:] +# if not objs: +# return +# types = [get_link(obj_, include_module=True) for obj_ in objs] +# items = [Item(type=Type(type_)) for type_ in types if type_] +# doc.set_section(Section("Bases", items=items)) + + +# def parse_source(doc: Docstring, obj: object) -> None: +# """Parse parameters' docstring to inspect type and description from source. + +# Examples: +# >>> from mkapi.core.base import Base +# >>> doc = Docstring() +# >>> parse_source(doc, Base) +# >>> section = doc["Parameters"] +# >>> section["name"].to_tuple() +# ('name', 'str, optional', 'Name of self.') +# >>> section = doc["Attributes"] +# >>> section["html"].to_tuple() +# ('html', 'str', 'HTML output after conversion.') +# """ +# signature = get_signature(obj) +# name = "Parameters" +# section: Section = signature[name] +# if name in doc: +# section = section.merge(doc[name], force=True) +# if section: +# doc.set_section(section, replace=True) + +# name = "Attributes" +# section: Section = signature[name] +# if name not in doc and not section: +# return +# doc[name].update(section) +# if is_dataclass(obj) and "Parameters" in doc: +# for item in doc["Parameters"].items: +# if item.name in section: +# doc[name].set_item(item) + + +# def postprocess_parameters(doc: Docstring, signature: Signature) -> None: +# if "Parameters" not in doc: +# return +# for item in doc["Parameters"].items: +# description = item.description +# if "{default}" in description.name and item.name in signature: +# default = signature.defaults[item.name] +# description.markdown = description.name.replace("{default}", default) + + +# def postprocess_returns(doc: Docstring, signature: Signature) -> None: +# for name in ["Returns", "Yields"]: +# if name in doc: +# section = doc[name] +# if not section.type: +# section.type = Type(getattr(signature, name.lower())) + + +# def postprocess_sections(doc: Docstring, obj: object) -> None: +# sections: list[Section] = [] +# for section in doc.sections: +# if section.name not in ["Example", "Examples"]: +# for base in section: +# base.markdown = replace_link(obj, base.markdown) +# if section.name in ["Note", "Notes", "Warning", "Warnings"]: +# markdown = add_admonition(section.name, section.markdown) +# if sections and sections[-1].name == "": +# sections[-1].markdown += "\n\n" + markdown +# continue +# section.name = "" +# section.markdown = markdown +# sections.append(section) +# doc.sections = sections + + +# def set_docstring_type(doc: Docstring, signature: Signature, obj: object) -> None: +# from mkapi.core.node import get_kind + +# if "Returns" not in doc and "Yields" not in doc: +# if get_kind(obj) == "generator": +# doc.type = Type(signature.yields) +# else: +# doc.type = Type(signature.returns) + + +# def postprocess_docstring(doc: Docstring, obj: object) -> None: +# """Docstring prostprocess.""" +# parse_bases(doc, obj) +# parse_source(doc, obj) + +# if not callable(obj): +# return + +# signature = get_signature(obj) +# if not signature.signature: +# return + +# postprocess_parameters(doc, signature) +# postprocess_returns(doc, signature) +# postprocess_sections(doc, obj) +# set_docstring_type(doc, signature, obj) + + +# def get_docstring(obj: object) -> Docstring: +# """Return a [Docstring]) instance.""" +# doc = inspect.getdoc(obj) +# if not doc: +# return Docstring() +# sections = [get_section(*section_args) for section_args in split_section(doc)] +# docstring = Docstring(sections) +# postprocess_docstring(docstring, obj) +# return docstring diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py deleted file mode 100644 index c287fd19..00000000 --- a/src/mkapi/inspect.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Inspect module.""" -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class Ref: - """Reference of type.""" - - fullname: str diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py deleted file mode 100644 index e7ed9bd7..00000000 --- a/src/mkapi/objects.py +++ /dev/null @@ -1,32 +0,0 @@ -# """Object class.""" -# from __future__ import annotations - -# import ast -# from dataclasses import dataclass -# from typing import TYPE_CHECKING, TypeAlias - -# from mkapi.ast import iter_definition_nodes - -# if TYPE_CHECKING: -# from collections.abc import Iterator -# from pathlib import Path - -# from mkapi.modules import Module - - -# Node: TypeAlias = ast.ClassDef | ast.Module | ast.FunctionDef - - -# @dataclass -# class Object: -# """Object class.""" - -# name: str -# path: Path -# source: str -# module: Module -# node: Node - - -# def iter_objects(module: Module) -> Iterator: -# pass diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 22f43820..7a9c4916 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -57,8 +57,7 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): server = None - def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002 - """Insert `src_dirs` to `sys.path`.""" + def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002, D102 _insert_sys_path(self.config) _update_config(config, self) if "admonition" not in config.markdown_extensions: diff --git a/src_old/mkapi/templates/bases.jinja2 b/src/mkapi/templates/bases.jinja2 similarity index 100% rename from src_old/mkapi/templates/bases.jinja2 rename to src/mkapi/templates/bases.jinja2 diff --git a/src_old/mkapi/templates/code.jinja2 b/src/mkapi/templates/code.jinja2 similarity index 100% rename from src_old/mkapi/templates/code.jinja2 rename to src/mkapi/templates/code.jinja2 diff --git a/src_old/mkapi/templates/docstring.jinja2 b/src/mkapi/templates/docstring.jinja2 similarity index 100% rename from src_old/mkapi/templates/docstring.jinja2 rename to src/mkapi/templates/docstring.jinja2 diff --git a/src_old/mkapi/templates/items.jinja2 b/src/mkapi/templates/items.jinja2 similarity index 100% rename from src_old/mkapi/templates/items.jinja2 rename to src/mkapi/templates/items.jinja2 diff --git a/src_old/mkapi/templates/macros.jinja2 b/src/mkapi/templates/macros.jinja2 similarity index 100% rename from src_old/mkapi/templates/macros.jinja2 rename to src/mkapi/templates/macros.jinja2 diff --git a/src_old/mkapi/templates/member.jinja2 b/src/mkapi/templates/member.jinja2 similarity index 100% rename from src_old/mkapi/templates/member.jinja2 rename to src/mkapi/templates/member.jinja2 diff --git a/src_old/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 similarity index 100% rename from src_old/mkapi/templates/module.jinja2 rename to src/mkapi/templates/module.jinja2 diff --git a/src_old/mkapi/templates/node.jinja2 b/src/mkapi/templates/node.jinja2 similarity index 100% rename from src_old/mkapi/templates/node.jinja2 rename to src/mkapi/templates/node.jinja2 diff --git a/src_old/mkapi/templates/object.jinja2 b/src/mkapi/templates/object.jinja2 similarity index 100% rename from src_old/mkapi/templates/object.jinja2 rename to src/mkapi/templates/object.jinja2 diff --git a/src_old/mkapi/theme/css/mkapi-common.css b/src/mkapi/themes/css/mkapi-common.css similarity index 100% rename from src_old/mkapi/theme/css/mkapi-common.css rename to src/mkapi/themes/css/mkapi-common.css diff --git a/src_old/mkapi/theme/css/mkapi-ivory.css b/src/mkapi/themes/css/mkapi-ivory.css similarity index 100% rename from src_old/mkapi/theme/css/mkapi-ivory.css rename to src/mkapi/themes/css/mkapi-ivory.css diff --git a/src_old/mkapi/theme/css/mkapi-mkdocs.css b/src/mkapi/themes/css/mkapi-mkdocs.css similarity index 100% rename from src_old/mkapi/theme/css/mkapi-mkdocs.css rename to src/mkapi/themes/css/mkapi-mkdocs.css diff --git a/src_old/mkapi/theme/css/mkapi-readthedocs.css b/src/mkapi/themes/css/mkapi-readthedocs.css similarity index 100% rename from src_old/mkapi/theme/css/mkapi-readthedocs.css rename to src/mkapi/themes/css/mkapi-readthedocs.css diff --git a/src_old/mkapi/theme/js/mkapi.js b/src/mkapi/themes/js/mkapi.js similarity index 100% rename from src_old/mkapi/theme/js/mkapi.js rename to src/mkapi/themes/js/mkapi.js diff --git a/src_old/mkapi/theme/mkapi.yml b/src/mkapi/themes/mkapi.yml similarity index 100% rename from src_old/mkapi/theme/mkapi.yml rename to src/mkapi/themes/mkapi.yml diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index a5414b2f..121edba8 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -44,3 +44,122 @@ def find_submodule_names(name: str) -> list[str]: """Return a list of submodules.""" names = iter_submodule_names(name) return sorted(names, key=lambda name: not _is_package(name)) + + +def split_type(markdown: str) -> tuple[str, str]: + """Return a tuple of (type, markdown) splitted by a colon. + + Examples: + >>> split_type("int : Integer value.") + ('int', 'Integer value.') + >>> split_type("abc") + ('', 'abc') + >>> split_type("") + ('', '') + """ + line = markdown.split("\n", maxsplit=1)[0] + if (index := line.find(":")) != -1: + type_ = line[:index].strip() + markdown = markdown[index + 1 :].strip() + return type_, markdown + return "", markdown + + +def delete_ptags(html: str) -> str: + """Return HTML without

tag. + + Examples: + >>> delete_ptags("

para1

para2

") + 'para1
para2' + """ + html = html.replace("

", "").replace("

", "
") + if html.endswith("
"): + html = html[:-4] + return html + + +def get_indent(line: str) -> int: + """Return the number of indent of a line. + + Examples: + >>> get_indent("abc") + 0 + >>> get_indent(" abc") + 2 + >>> get_indent("") + -1 + """ + for k, x in enumerate(line): + if x != " ": + return k + return -1 + + +def join_without_indent( + lines: list[str] | str, + start: int = 0, + stop: int | None = None, +) -> str: + r"""Return a joint string without indent. + + Examples: + >>> join_without_indent(["abc", "def"]) + 'abc\ndef' + >>> join_without_indent([" abc", " def"]) + 'abc\ndef' + >>> join_without_indent([" abc", " def ", ""]) + 'abc\n def' + >>> join_without_indent([" abc", " def", " ghi"]) + 'abc\n def\n ghi' + >>> join_without_indent([" abc", " def", " ghi"], stop=2) + 'abc\n def' + >>> join_without_indent([]) + '' + """ + if not lines: + return "" + if isinstance(lines, str): + return join_without_indent(lines.split("\n")) + indent = get_indent(lines[start]) + return "\n".join(line[indent:] for line in lines[start:stop]).strip() + + +def _splitter(text: str) -> Iterator[str]: + start = 0 + in_code = False + lines = text.split("\n") + for stop, line in enumerate(lines, 1): + if ">>>" in line and not in_code: + if start < stop - 1: + yield "\n".join(lines[start : stop - 1]) + start = stop - 1 + in_code = True + elif not line.strip() and in_code: + yield join_without_indent(lines, start, stop) + start = stop + in_code = False + if start < len(lines): + yield join_without_indent(lines, start, len(lines)) + + +def add_fence(text: str) -> str: + """Add fence in `>>>` statements.""" + blocks = [] + for block in _splitter(text): + if block.startswith(">>>"): + block = f"~~~python\n{block}\n~~~\n" # noqa: PLW2901 + blocks.append(block) + return "\n".join(blocks).strip() + + +def add_admonition(name: str, markdown: str) -> str: + """Add admonition in note and/or warning sections.""" + if name.startswith("Note"): + kind = "note" + elif name.startswith("Warning"): + kind = "warning" + else: + kind = name.lower() + lines = [" " + line if line else "" for line in markdown.split("\n")] + lines.insert(0, f'!!! {kind} "{name}"') + return "\n".join(lines) diff --git a/src_old/mkapi/__about__.py b/src_old/mkapi/__about__.py deleted file mode 100644 index d4d4a279..00000000 --- a/src_old/mkapi/__about__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Dynamic version.""" - -__version__ = "1.1.0" diff --git a/src_old/mkapi/__init__.py b/src_old/mkapi/__init__.py deleted file mode 100644 index 4d7d354c..00000000 --- a/src_old/mkapi/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -__version__ = "1.0.14" - -from mkapi.core.module import get_module -from mkapi.core.node import get_node -from mkapi.main import display, get_html - -__all__ = ["get_node", "get_module", "get_html", "display"] diff --git a/src_old/mkapi/core/docstring.py b/src_old/mkapi/core/docstring.py deleted file mode 100644 index 801ea3dd..00000000 --- a/src_old/mkapi/core/docstring.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Parse docstring.""" -import inspect -import re -from collections.abc import Iterator -from dataclasses import is_dataclass - -from mkapi.core.base import Docstring, Inline, Item, Section, Type -from mkapi.core.link import get_link, replace_link -from mkapi.core.object import get_mro -from mkapi.core.preprocess import add_admonition, get_indent, join_without_indent -from mkapi.inspect.signature import Signature, get_signature - -SECTIONS = [ - "Args", - "Arguments", - "Attributes", - "Example", - "Examples", - "Note", - "Notes", - "Parameters", - "Raises", - "Returns", - "References", - "See Also", - "Todo", - "Warning", - "Warnings", - "Warns", - "Yield", - "Yields", -] - - -def rename_section(name: str) -> str: # noqa: D103 - if name in ["Args", "Arguments"]: - return "Parameters" - if name == "Warns": - return "Warnings" - return name - - -def section_heading(line: str) -> tuple[str, str]: - """Return a tuple of (section name, style name). - - Args: - line: Docstring line. - - Examples: - >>> section_heading("Args:") - ('Args', 'google') - >>> section_heading("Raises") - ('Raises', 'numpy') - >>> section_heading("other") - ('', '') - """ - if line in SECTIONS: - return line, "numpy" - if line.endswith(":") and line[:-1] in SECTIONS: - return line[:-1], "google" - return "", "" - - -def split_section(doc: str) -> Iterator[tuple[str, str, str]]: - r"""Yield a tuple of (section name, contents, style). - - Args: - doc: Docstring - - Examples: - >>> doc = "abc\n\nArgs:\n x: X\n" - >>> it = split_section(doc) - >>> next(it) - ('', 'abc', '') - >>> next(it) - ('Parameters', 'x: X', 'google') - """ - name = style = "" - start = indent = 0 - lines = [x.rstrip() for x in doc.split("\n")] - for stop, line in enumerate(lines, 1): - next_indent = -1 if stop == len(lines) else get_indent(lines[stop]) - if not line and next_indent < indent and name: - if start < stop - 1: - yield name, join_without_indent(lines[start : stop - 1]), style - start, name = stop, "" - else: - section, style_ = section_heading(line) - if section: - if start < stop - 1: - yield name, join_without_indent(lines[start : stop - 1]), style - style, start, name = style_, stop, rename_section(section) - if style == "numpy": # skip underline without counting the length. - start += 1 - indent = next_indent - if start < len(lines): - yield name, join_without_indent(lines[start:]), style - - -def split_parameter(doc: str) -> Iterator[list[str]]: - """Yield a list of parameter string. - - Args: - doc: Docstring - """ - start = stop = 0 - lines = [x.rstrip() for x in doc.split("\n")] - for stop, _ in enumerate(lines, 1): - next_indent = 0 if stop == len(lines) else get_indent(lines[stop]) - if next_indent == 0: - yield lines[start:stop] - start = stop - - -PARAMETER_PATTERN = { - "google": re.compile(r"(.*?)\s*?\((.*?)\)"), - "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), -} - - -def parse_parameter(lines: list[str], style: str) -> Item: - """Return a Item instance corresponding to a parameter. - - Args: - lines: Splitted parameter docstring lines. - style: Docstring style. `google` or `numpy`. - """ - if style == "google": - name, _, line = lines[0].partition(":") - name, parsed = name.strip(), [line.strip()] - else: - name, parsed = lines[0].strip(), [] - if len(lines) > 1: - indent = get_indent(lines[1]) - for line in lines[1:]: - parsed.append(line[indent:]) - if m := re.match(PARAMETER_PATTERN[style], name): - name, type_ = m.group(1), m.group(2) - else: - type_ = "" - return Item(name, Type(type_), Inline("\n".join(parsed))) - - -def parse_parameters(doc: str, style: str) -> list[Item]: - """Return a list of Item.""" - return [parse_parameter(lines, style) for lines in split_parameter(doc)] - - -def parse_returns(doc: str, style: str) -> tuple[str, str]: - """Return a tuple of (type, markdown).""" - type_, lines = "", doc.split("\n") - if style == "google": - if ":" in lines[0]: - type_, _, lines[0] = lines[0].partition(":") - type_ = type_.strip() - lines[0] = lines[0].strip() - else: - type_, lines = lines[0].strip(), lines[1:] - return type_, join_without_indent(lines) - - -def get_section(name: str, doc: str, style: str) -> Section: - """Return a [Section]() instance.""" - type_ = markdown = "" - items = [] - if name in ["Parameters", "Attributes", "Raises"]: - items = parse_parameters(doc, style) - elif name in ["Returns", "Yields"]: - type_, markdown = parse_returns(doc, style) - else: - markdown = doc - return Section(name, markdown, items, Type(type_)) - - -def parse_bases(doc: Docstring, obj: object) -> None: - """Parse base classes to create a Base(s) line.""" - if not inspect.isclass(obj) or not hasattr(obj, "mro"): - return - objs = get_mro(obj)[1:] - if not objs: - return - types = [get_link(obj_, include_module=True) for obj_ in objs] - items = [Item(type=Type(type_)) for type_ in types if type_] - doc.set_section(Section("Bases", items=items)) - - -def parse_source(doc: Docstring, obj: object) -> None: - """Parse parameters' docstring to inspect type and description from source. - - Examples: - >>> from mkapi.core.base import Base - >>> doc = Docstring() - >>> parse_source(doc, Base) - >>> section = doc["Parameters"] - >>> section["name"].to_tuple() - ('name', 'str, optional', 'Name of self.') - >>> section = doc["Attributes"] - >>> section["html"].to_tuple() - ('html', 'str', 'HTML output after conversion.') - """ - signature = get_signature(obj) - name = "Parameters" - section: Section = signature[name] - if name in doc: - section = section.merge(doc[name], force=True) - if section: - doc.set_section(section, replace=True) - - name = "Attributes" - section: Section = signature[name] - if name not in doc and not section: - return - doc[name].update(section) - if is_dataclass(obj) and "Parameters" in doc: - for item in doc["Parameters"].items: - if item.name in section: - doc[name].set_item(item) - - -def postprocess_parameters(doc: Docstring, signature: Signature) -> None: # noqa: D103 - if "Parameters" not in doc: - return - for item in doc["Parameters"].items: - description = item.description - if "{default}" in description.name and item.name in signature: - default = signature.defaults[item.name] - description.markdown = description.name.replace("{default}", default) - - -def postprocess_returns(doc: Docstring, signature: Signature) -> None: # noqa: D103 - for name in ["Returns", "Yields"]: - if name in doc: - section = doc[name] - if not section.type: - section.type = Type(getattr(signature, name.lower())) - - -def postprocess_sections(doc: Docstring, obj: object) -> None: # noqa: D103 - sections: list[Section] = [] - for section in doc.sections: - if section.name not in ["Example", "Examples"]: - for base in section: - base.markdown = replace_link(obj, base.markdown) - if section.name in ["Note", "Notes", "Warning", "Warnings"]: - markdown = add_admonition(section.name, section.markdown) - if sections and sections[-1].name == "": - sections[-1].markdown += "\n\n" + markdown - continue - section.name = "" - section.markdown = markdown - sections.append(section) - doc.sections = sections - - -def set_docstring_type(doc: Docstring, signature: Signature, obj: object) -> None: # noqa: D103 - from mkapi.core.node import get_kind - - if "Returns" not in doc and "Yields" not in doc: - if get_kind(obj) == "generator": - doc.type = Type(signature.yields) - else: - doc.type = Type(signature.returns) - - -def postprocess_docstring(doc: Docstring, obj: object) -> None: - """Docstring prostprocess.""" - parse_bases(doc, obj) - parse_source(doc, obj) - - if not callable(obj): - return - - signature = get_signature(obj) - if not signature.signature: - return - - postprocess_parameters(doc, signature) - postprocess_returns(doc, signature) - postprocess_sections(doc, obj) - set_docstring_type(doc, signature, obj) - - -def get_docstring(obj: object) -> Docstring: - """Return a [Docstring]() instance.""" - doc = inspect.getdoc(obj) - if not doc: - return Docstring() - sections = [get_section(*section_args) for section_args in split_section(doc)] - docstring = Docstring(sections) - postprocess_docstring(docstring, obj) - return docstring diff --git a/src_old/mkapi/core/filter.py b/src_old/mkapi/core/filter.py deleted file mode 100644 index db115974..00000000 --- a/src_old/mkapi/core/filter.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Filter functions.""" - - -def split_filters(name: str) -> tuple[str, list[str]]: - """Split filters written after `|`s. - - Examples: - >>> split_filters("a.b.c") - ('a.b.c', []) - >>> split_filters("a.b.c|upper|strict") - ('a.b.c', ['upper', 'strict']) - >>> split_filters("|upper|strict") - ('', ['upper', 'strict']) - >>> split_filters("") - ('', []) - """ - index = name.find("|") - if index == -1: - return name, [] - name, filters = name[:index], name[index + 1 :] - return name, filters.split("|") - - -def update_filters(org: list[str], update: list[str]) -> list[str]: - """Update filters. - - Examples: - >>> update_filters(['upper'], ['lower']) - ['lower'] - >>> update_filters(['lower'], ['upper']) - ['upper'] - >>> update_filters(['long'], ['short']) - ['short'] - >>> update_filters(['short'], ['long']) - ['long'] - """ - filters = org + update - for x, y in [["lower", "upper"], ["long", "short"]]: - if x in org and y in update: - del filters[filters.index(x)] - if y in org and x in update: - del filters[filters.index(y)] - - return filters diff --git a/src_old/mkapi/core/preprocess.py b/src_old/mkapi/core/preprocess.py deleted file mode 100644 index b780d2b7..00000000 --- a/src_old/mkapi/core/preprocess.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Preprocess functions.""" -from collections.abc import Iterator - - -def split_type(markdown: str) -> tuple[str, str]: - """Return a tuple of (type, markdown) splitted by a colon. - - Examples: - >>> split_type("int : Integer value.") - ('int', 'Integer value.') - >>> split_type("abc") - ('', 'abc') - >>> split_type("") - ('', '') - """ - line = markdown.split("\n", maxsplit=1)[0] - if (index := line.find(":")) != -1: - type_ = line[:index].strip() - markdown = markdown[index + 1 :].strip() - return type_, markdown - return "", markdown - - -def delete_ptags(html: str) -> str: - """Return HTML without

tag. - - Examples: - >>> delete_ptags("

para1

para2

") - 'para1
para2' - """ - html = html.replace("

", "").replace("

", "
") - if html.endswith("
"): - html = html[:-4] - return html - - -def get_indent(line: str) -> int: - """Return the number of indent of a line. - - Examples: - >>> get_indent("abc") - 0 - >>> get_indent(" abc") - 2 - >>> get_indent("") - -1 - """ - for k, x in enumerate(line): - if x != " ": - return k - return -1 - - -def join_without_indent( - lines: list[str] | str, - start: int = 0, - stop: int | None = None, -) -> str: - r"""Return a joint string without indent. - - Examples: - >>> join_without_indent(["abc", "def"]) - 'abc\ndef' - >>> join_without_indent([" abc", " def"]) - 'abc\ndef' - >>> join_without_indent([" abc", " def ", ""]) - 'abc\n def' - >>> join_without_indent([" abc", " def", " ghi"]) - 'abc\n def\n ghi' - >>> join_without_indent([" abc", " def", " ghi"], stop=2) - 'abc\n def' - >>> join_without_indent([]) - '' - """ - if not lines: - return "" - if isinstance(lines, str): - return join_without_indent(lines.split("\n")) - indent = get_indent(lines[start]) - return "\n".join(line[indent:] for line in lines[start:stop]).strip() - - -def _splitter(text: str) -> Iterator[str]: - start = 0 - in_code = False - lines = text.split("\n") - for stop, line in enumerate(lines, 1): - if ">>>" in line and not in_code: - if start < stop - 1: - yield "\n".join(lines[start : stop - 1]) - start = stop - 1 - in_code = True - elif not line.strip() and in_code: - yield join_without_indent(lines, start, stop) - start = stop - in_code = False - if start < len(lines): - yield join_without_indent(lines, start, len(lines)) - - -def add_fence(text: str) -> str: - """Add fence in `>>>` statements.""" - blocks = [] - for block in _splitter(text): - if block.startswith(">>>"): - block = f"~~~python\n{block}\n~~~\n" # noqa: PLW2901 - blocks.append(block) - return "\n".join(blocks).strip() - - -def add_admonition(name: str, markdown: str) -> str: - """Add admonition in note and/or warning sections.""" - if name.startswith("Note"): - kind = "note" - elif name.startswith("Warning"): - kind = "warning" - else: - kind = name.lower() - lines = [" " + line if line else "" for line in markdown.split("\n")] - lines.insert(0, f'!!! {kind} "{name}"') - return "\n".join(lines) diff --git a/src_old/mkapi/core/structure.py b/src_old/mkapi/core/structure.py index 0264b584..1c4a14f0 100644 --- a/src_old/mkapi/core/structure.py +++ b/src_old/mkapi/core/structure.py @@ -1,17 +1,13 @@ -"""Base class of [Node](mkapi.core.node.Node) and [Module](mkapi.core.module.Module).""" -from collections.abc import Iterator +"""Base class of [Node](mkapi.nodes.Node) and [Module](mkapi.modules.Module).""" +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any, Self - -from mkapi.core.base import Base, Type -from mkapi.core.docstring import Docstring, get_docstring -from mkapi.core.object import ( - get_origin, - get_qualname, - get_sourcefile_and_lineno, - split_prefix_and_name, -) -from mkapi.inspect.signature import Signature, get_signature +from typing import TYPE_CHECKING, Any, Self + +from mkapi.docstring import Base, Docstring, Type, parse_docstring + +if TYPE_CHECKING: + from collections.abc import Iterator @dataclass diff --git a/src_old/mkapi/inspect/__init__.py b/src_old/mkapi/inspect/__init__.py deleted file mode 100644 index 8fe7a65c..00000000 --- a/src_old/mkapi/inspect/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Inspection package.""" diff --git a/src_old/mkapi/inspect/attribute.py b/src_old/mkapi/inspect/attribute.py deleted file mode 100644 index 0921224f..00000000 --- a/src_old/mkapi/inspect/attribute.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Functions that inspect attributes from source code.""" -from __future__ import annotations - -import ast -import dataclasses -import inspect -import warnings -from ast import AST -from functools import lru_cache -from typing import TYPE_CHECKING, Any, TypeGuard - -from mkapi.core.preprocess import join_without_indent - -if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator - from types import ModuleType - - from _typeshed import DataclassInstance - - -def getsource_dedent(obj) -> str: # noqa: ANN001 - """Return the text of the source code for an object without exception.""" - try: - source = inspect.getsource(obj) - except OSError: - return "" - return join_without_indent(source) - - -def parse_attribute(node: ast.Attribute) -> str: # noqa: D103 - return ".".join([parse_node(node.value), node.attr]) - - -def parse_subscript(node: ast.Subscript) -> str: # noqa: D103 - value = parse_node(node.value) - slice_ = parse_node(node.slice) - return f"{value}[{slice_}]" - - -def parse_constant(node: ast.Constant) -> str: # noqa: D103 - value = node.value - if value is Ellipsis: - return "..." - if not isinstance(value, str): - warnings.warn("Not `str`", stacklevel=1) - return value - - -def parse_tuple(node: ast.Tuple) -> str: # noqa: D103 - return ", ".join(parse_node(n) for n in node.elts) - - -def parse_list(node: ast.List) -> str: # noqa: D103 - return "[" + ", ".join(parse_node(n) for n in node.elts) + "]" - - -PARSE_NODE_FUNCTIONS: list[tuple[type, Callable[..., str] | str]] = [ - (ast.Attribute, parse_attribute), - (ast.Subscript, parse_subscript), - (ast.Constant, parse_constant), - (ast.Tuple, parse_tuple), - (ast.List, parse_list), - (ast.Name, "id"), -] - - -def parse_node(node: AST) -> str: - """Return a string expression for AST node.""" - for type_, parse in PARSE_NODE_FUNCTIONS: - if isinstance(node, type_): - node_str = parse(node) if callable(parse) else getattr(node, parse) - return node_str if isinstance(node_str, str) else str(node_str) - return ast.unparse(node) - - -def get_attribute_list( - nodes: Iterable[AST], - module: ModuleType, - *, - is_module: bool = False, -) -> list[tuple[str, int, Any]]: - """Retrun list of tuple of (name, lineno, type).""" - attr_dict: dict[tuple[str, int], Any] = {} - linenos: dict[int, int] = {} - - def update(name, lineno, type_=()) -> None: # noqa: ANN001 - if type_ or (name, lineno) not in attr_dict: - attr_dict[(name, lineno)] = type_ - linenos[lineno] = linenos.get(lineno, 0) + 1 - - members = dict(inspect.getmembers(module)) - for node in nodes: - if isinstance(node, ast.AnnAssign): - type_str = parse_node(node.annotation) - type_ = members.get(type_str, type_str) - update(parse_node(node.target), node.lineno, type_) - elif isinstance(node, ast.Attribute): # and isinstance(node.ctx, ast.Store): - update(parse_node(node), node.lineno) - elif is_module and isinstance(node, ast.Assign): - update(parse_node(node.targets[0]), node.lineno) - attrs = [(name, lineno, type_) for (name, lineno), type_ in attr_dict.items()] - attrs = [attr for attr in attrs if linenos[attr[1]] == 1] - return sorted(attrs, key=lambda attr: attr[1]) - - -def get_description(lines: list[str], lineno: int) -> str: - """Return description from lines of source.""" - index = lineno - 1 - line = lines[index] - if " #: " in (line := lines[lineno - 1]): - return line.split(" #: ")[1].strip() - if lineno > 1 and (line := lines[lineno - 2].strip()).startswith("#: "): - return line[3:].strip() - if lineno < len(lines): - docs, in_doc, mark = [], False, "" - for line_ in lines[lineno:]: - line = line_.strip() - if in_doc: - if line.endswith(mark): - docs.append(line[:-3]) - return "\n".join(docs).strip() - docs.append(line) - elif line.startswith(("'''", '"""')): - in_doc, mark = True, line[:3] - if line.endswith(mark): - return line[3:-3] - docs.append(line[3:]) - elif not line: - return "" - return "" - - -def get_attribute_dict( - attr_list: list[tuple[str, int, Any]], - source: str, - prefix: str = "", -) -> dict[str, tuple[Any, str]]: - """Return an attribute dictionary.""" - attrs: dict[str, tuple[Any, str]] = {} - lines = source.split("\n") - for k, (name, lineno, type_) in enumerate(attr_list): - if not name.startswith(prefix): - continue - name = name[len(prefix) :] # noqa: PLW2901 - stop = attr_list[k + 1][1] - 1 if k < len(attr_list) - 1 else len(lines) - description = get_description(lines[:stop], lineno) - if type_: - attrs[name] = (type_, description) # Assignment with type wins. - elif name not in attrs: - attrs[name] = None, description - return attrs - - -def _nodeiter_before_function(node: AST) -> Iterator[AST]: - for x in ast.iter_child_nodes(node): - if isinstance(x, ast.FunctionDef): - break - yield x - - -def get_dataclass_attributes(cls: type) -> dict[str, tuple[Any, str]]: - """Return a dictionary that maps attribute name to a tuple of (type, description). - - Args: - cls: Dataclass object. - - Examples: - >>> from mkapi.core.base import Item, Type, Inline - >>> attrs = get_dataclass_attributes(Item) - >>> attrs["type"][0] is Type - True - >>> attrs["description"][0] is Inline - True - """ - source = getsource_dedent(cls) - module = inspect.getmodule(cls) - if not source or not module: - return {} - - node = ast.parse(source).body[0] - nodes = _nodeiter_before_function(node) - attr_lineno = get_attribute_list(nodes, module) - attr_dict = get_attribute_dict(attr_lineno, source) - - attrs: dict[str, tuple[Any, str]] = {} - for field in dataclasses.fields(cls): - if field.type != dataclasses.InitVar: - attrs[field.name] = field.type, "" - for name, (type_, description) in attr_dict.items(): - attrs[name] = attrs.get(name, [type_])[0], description - - return attrs - - -def get_class_attributes(cls: type) -> dict[str, tuple[Any, str]]: - """Return a dictionary that maps attribute name to a tuple of (type, description). - - Args: - cls: Class object. - """ - source = getsource_dedent(cls) - module = inspect.getmodule(cls) - if not source or not module: - return {} - - node = ast.parse(source) - nodes = ast.walk(node) - attr_lineno = get_attribute_list(nodes, module) - return get_attribute_dict(attr_lineno, source, prefix="self.") - - -def get_module_attributes(module: ModuleType) -> dict[str, tuple[Any, str]]: - """Return a dictionary that maps attribute name to a tuple of (type, description). - - Args: - module: Module object. - - Examples: - >>> from mkapi.core import renderer - >>> attrs = get_module_attributes(renderer) - >>> attrs["renderer"][0] is renderer.Renderer - True - """ - source = getsource_dedent(module) - if not source: - return {} - - node = ast.parse(source) - nodes = ast.iter_child_nodes(node) - attr_lineno = get_attribute_list(nodes, module, is_module=True) - return get_attribute_dict(attr_lineno, source) - - -def isdataclass(obj: object) -> TypeGuard[type[DataclassInstance]]: - """Return True if obj is a dataclass.""" - return dataclasses.is_dataclass(obj) and isinstance(obj, type) - - -ATTRIBUTES_FUNCTIONS = [ - (isdataclass, get_dataclass_attributes), - (inspect.isclass, get_class_attributes), - (inspect.ismodule, get_module_attributes), -] - - -@lru_cache(maxsize=1000) -def get_attributes(obj: object) -> dict[str, tuple[Any, str]]: - """Return a dictionary that maps attribute name to a tuple of (type, description). - - Args: - obj: Object. - - See Also: - get_class_attributes_, get_dataclass_attributes_, get_module_attributes_. - """ - for is_, get in ATTRIBUTES_FUNCTIONS: - if is_(obj): - return get(obj) - return {} diff --git a/src_old/mkapi/inspect/signature.py b/src_old/mkapi/inspect/signature.py deleted file mode 100644 index c81b5f35..00000000 --- a/src_old/mkapi/inspect/signature.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Signature class that inspects object and creates signature and types.""" -import inspect -from dataclasses import InitVar, dataclass, field, is_dataclass -from functools import lru_cache -from typing import Any - -from mkapi.core import preprocess -from mkapi.core.base import Inline, Item, Section, Type -from mkapi.inspect.attribute import get_attributes -from mkapi.inspect.typing import type_string - - -@dataclass -class Signature: - """Signature class. - - Args: - obj: Object - - Attributes: - signature: `inspect.Signature` instance. - parameters: Parameters section. - defaults: Default value dictionary. Key is parameter name and - value is default value. - attributes: Attributes section. - returns: Returned type string. Used in Returns section. - yields: Yielded type string. Used in Yields section. - """ - - obj: Any = field(default=None, repr=False) - signature: inspect.Signature | None = field(default=None, init=False) - parameters: Section = field(default_factory=Section, init=False) - defaults: dict[str, Any] = field(default_factory=dict, init=False) - attributes: Section = field(default_factory=Section, init=False) - returns: str = field(default="", init=False) - yields: str = field(default="", init=False) - - def __post_init__(self) -> None: - if self.obj is None: - return - try: - self.signature = inspect.signature(self.obj) - except (TypeError, ValueError): - self.set_attributes() - return - - items, self.defaults = get_parameters(self.obj) - self.parameters = Section("Parameters", items=items) - self.set_attributes() - return_type = self.signature.return_annotation - self.returns = type_string(return_type, kind="returns", obj=self.obj) - self.yields = type_string(return_type, kind="yields", obj=self.obj) - - def __contains__(self, name: str) -> bool: - return name in self.parameters - - def __getitem__(self, name: str): # noqa: ANN204 - return getattr(self, name.lower()) - - def __str__(self) -> str: - args = self.arguments - return "" if args is None else "(" + ", ".join(args) + ")" - - @property - def arguments(self) -> list[str] | None: - """Return argument list.""" - if self.obj is None or not callable(self.obj): - return None - - args = [] - for item in self.parameters.items: - arg = item.name - if self.defaults[arg] != inspect.Parameter.empty: - arg += "=" + self.defaults[arg] - args.append(arg) - return args - - def set_attributes(self) -> None: - """Set attributes. - - Examples: - >>> from mkapi.core.base import Base - >>> s = Signature(Base) - >>> s.parameters['name'].to_tuple() - ('name', 'str, optional', 'Name of self.') - >>> s.attributes['html'].to_tuple() - ('html', 'str', 'HTML output after conversion.') - """ - items = [] - for name, (tp, description) in get_attributes(self.obj).items(): - type_str = type_string(tp, obj=self.obj) if tp else "" - if not type_str: - type_str, description = preprocess.split_type(description) # noqa: PLW2901 - - item = Item(name, Type(type_str), Inline(description)) - if is_dataclass(self.obj): - if name in self.parameters: - self.parameters[name].set_description(item.description) - if self.obj.__dataclass_fields__[name].type != InitVar: - items.append(item) - else: - items.append(item) - self.attributes = Section("Attributes", items=items) - - def split(self, sep: str = ",") -> list[str]: - """Return a list of substring.""" - return str(self).split(sep) - - -def get_parameters(obj) -> tuple[list[Item], dict[str, Any]]: # noqa: ANN001 - """Return a tuple of parameters section and defalut values.""" - signature = inspect.signature(obj) - items: list[Item] = [] - defaults: dict[str, Any] = {} - - for name, parameter in signature.parameters.items(): - if name == "self": - continue - if parameter.kind is inspect.Parameter.VAR_POSITIONAL: - key = f"*{name}" - elif parameter.kind is inspect.Parameter.VAR_KEYWORD: - key = f"**{name}" - else: - key = name - type_str = type_string(parameter.annotation, obj=obj) - if (default := parameter.default) == inspect.Parameter.empty: - defaults[key] = default - else: - defaults[key] = f"{default!r}" - if not type_str: - type_str = "optional" - elif not type_str.endswith(", optional"): - type_str += ", optional" - items.append(Item(key, Type(type_str))) - - return items, defaults - - -@lru_cache(maxsize=1000) -def get_signature(obj: object) -> Signature: - """Return a `Signature` object for `obj`.""" - return Signature(obj) diff --git a/src_old/mkapi/inspect/typing.py b/src_old/mkapi/inspect/typing.py deleted file mode 100644 index d5985744..00000000 --- a/src_old/mkapi/inspect/typing.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Type string.""" -import inspect -from dataclasses import InitVar -from types import EllipsisType, NoneType, UnionType -from typing import ForwardRef, Union, get_args, get_origin - -from mkapi.core.link import get_link - - -def type_string_none(tp: NoneType, obj: object) -> str: # noqa: D103, ARG001 - return "" - - -def type_string_ellipsis(tp: EllipsisType, obj: object) -> str: # noqa: D103, ARG001 - return "..." - - -def type_string_union(tp: UnionType, obj: object) -> str: # noqa: D103 - return " | ".join(type_string(arg, obj=obj) for arg in get_args(tp)) - - -def type_string_list(tp: list, obj: object) -> str: # noqa: D103 - args = ", ".join(type_string(arg, obj=obj) for arg in tp) - return f"[{args}]" - - -def type_string_str(tp: str, obj: object) -> str: - """Return a resolved name for `str`. - - Examples: - >>> from mkapi.core.base import Docstring - >>> type_string_str("Docstring", Docstring) - '[Docstring](!mkapi.core.base.Docstring)' - >>> type_string_str("invalid_object_name", Docstring) - 'invalid_object_name' - >>> type_string_str("no_module", 1) - 'no_module' - """ - if module := inspect.getmodule(obj): # noqa: SIM102 - if type_ := dict(inspect.getmembers(module)).get(tp): - return type_string(type_) - return tp - - -def type_string_forward_ref(tp: ForwardRef, obj: object) -> str: # noqa: D103 - return type_string_str(tp.__forward_arg__, obj) - - -def type_string_init_var(tp: InitVar, obj: object) -> str: # noqa: D103 - return type_string(tp.type, obj=obj) - - -TYPE_STRING_IS = {inspect.Parameter.empty: "", tuple: "()"} - -TYPE_STRING_FUNCTIONS = { - NoneType: type_string_none, - EllipsisType: type_string_ellipsis, - UnionType: type_string_union, - list: type_string_list, - str: type_string_str, - ForwardRef: type_string_forward_ref, - InitVar: type_string_init_var, -} - - -def register_type_string_function(type_, func) -> None: # noqa: ANN001, D103 - TYPE_STRING_FUNCTIONS[type_] = func - - -def type_string(tp, *, kind: str = "returns", obj: object = None) -> str: # noqa: ANN001 - """Return string expression for type. - - If possible, type string includes link. - - Args: - tp: type - kind: 'returns' or 'yields' - obj: Object - - Examples: - >>> type_string(str) - 'str' - >>> from mkapi.core.node import Node - >>> type_string(Node) - '[Node](!mkapi.core.node.Node)' - >>> type_string(None) - '' - >>> type_string(...) - '...' - >>> type_string([int, str]) - '[int, str]' - >>> type_string(tuple) - '()' - >>> type_string(tuple[int, str]) - '(int, str)' - >>> type_string(tuple[int, ...]) - '(int, ...)' - >>> type_string(int | str) - 'int | str' - >>> type_string("Node", obj=Node) - '[Node](!mkapi.core.node.Node)' - >>> from typing import List, get_origin - >>> type_string(List["Node"], obj=Node) - 'list[[Node](!mkapi.core.node.Node)]' - >>> from collections.abc import Iterable - >>> type_string(Iterable[int], kind="yields") - 'int' - >>> from dataclasses import InitVar - >>> type_string(InitVar[Node]) - '[Node](!mkapi.core.node.Node)' - """ - if kind == "yields": - return type_string_yields(tp, obj) - for obj_, str_ in TYPE_STRING_IS.items(): - if tp is obj_: - return str_ - for type_, func in TYPE_STRING_FUNCTIONS.items(): - if isinstance(tp, type_): - return func(tp, obj) - if get_origin(tp): - return type_string_origin_args(tp, obj) - return get_link(tp) - - -def origin_args_string_union(args: tuple, args_list: list[str]) -> str: # noqa: D103 - if len(args) == 2 and args[1] == type(None): # noqa: PLR2004 - return f"{args_list[0]} | None" - return " | ".join(args_list) - - -def origin_args_string_tuple(args: tuple, args_list: list[str]) -> str: # noqa: D103 - if not args: - return "()" - if len(args) == 1: - return f"({args_list[0]},)" - return "(" + ", ".join(args_list) + ")" - - -ORIGIN_FUNCTIONS = [ - (Union, origin_args_string_union), - (tuple, origin_args_string_tuple), -] - - -def type_string_origin_args(tp, obj: object = None) -> str: # noqa: ANN001 - """Return string expression for X[Y, Z, ...]. - - Args: - tp: type - obj: Object - - Examples: - >>> type_string_origin_args(list[str]) - 'list[str]' - >>> type_string_origin_args(tuple[str, int]) - '(str, int)' - >>> from typing import List, Tuple - >>> type_string_origin_args(List[Tuple[int, str]]) - 'list[(int, str)]' - >>> from mkapi.core.node import Node - >>> type_string_origin_args(list[Node]) - 'list[[Node](!mkapi.core.node.Node)]' - >>> from collections.abc import Callable, Iterator - >>> type_string_origin_args(Iterator[float]) - '[Iterator](!collections.abc.Iterator)[float]' - >>> type_string_origin_args(Callable[[], str]) - '[Callable](!collections.abc.Callable)[[], str]' - >>> from typing import Union, Optional - >>> type_string_origin_args(Union[int, str]) - 'int | str' - >>> type_string_origin_args(Optional[bool]) - 'bool | None' - """ - origin, args = get_origin(tp), get_args(tp) - args_list = [type_string(arg, obj=obj) for arg in args] - for origin_, func in ORIGIN_FUNCTIONS: - if origin is origin_: - return func(args, args_list) - origin_str = type_string(origin, obj=obj) - args_str = ", ".join(args_list) - return f"{origin_str}[{args_str}]" - - -def type_string_yields(tp, obj: object) -> str: # noqa: ANN001 - """Return string expression for type in generator. - - Examples: - >>> from collections.abc import Iterator - >>> type_string_yields(Iterator[int], None) - 'int' - >>> type_string_yields(int, None) # invalid type - '' - >>> type_string_yields(list[str, float], None) # invalid type - 'list[str, float]' - """ - if args := get_args(tp): - if len(args) == 1: - return type_string(args[0], obj=obj) - return type_string(tp, obj=obj) - return "" diff --git a/src_old/mkapi/main.py b/src_old/mkapi/main.py deleted file mode 100644 index 2da6cc34..00000000 --- a/src_old/mkapi/main.py +++ /dev/null @@ -1,20 +0,0 @@ -from markdown import Markdown - -converter = Markdown() - - -def get_html(node): - from mkapi.core.node import Node, get_node - - if not isinstance(node, Node): - node = get_node(node) - markdown = node.get_markdown() - html = converter.convert(markdown) - node.set_html(html) - return node.get_html() - - -def display(name): - from IPython.display import HTML - - return HTML(get_html(name)) diff --git a/src_old/mkapi/plugins/__init__.py b/src_old/mkapi/plugins/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src_old/mkapi/plugins/mkdocs.py b/src_old/mkapi/plugins/mkdocs.py deleted file mode 100644 index 295b5c29..00000000 --- a/src_old/mkapi/plugins/mkdocs.py +++ /dev/null @@ -1,305 +0,0 @@ -"""MkAPI Plugin class. - -MkAPI Plugin is a MkDocs plugin that creates Python API documentation -from Docstring. -""" -import atexit -import inspect -import logging -import os -import re -import shutil -import sys -from collections.abc import Callable -from pathlib import Path -from typing import TypeGuard - -import yaml -from mkdocs.config import config_options -from mkdocs.config.base import Config -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.livereload import LiveReloadServer -from mkdocs.plugins import BasePlugin -from mkdocs.structure.files import Files, get_files -from mkdocs.structure.nav import Navigation -from mkdocs.structure.pages import Page as MkDocsPage -from mkdocs.structure.toc import AnchorLink, TableOfContents -from mkdocs.utils.templates import TemplateContext - -import mkapi -from mkapi.core.filter import split_filters, update_filters -from mkapi.core.module import Module, get_module -from mkapi.core.object import get_object -from mkapi.core.page import Page as MkAPIPage - -logger = logging.getLogger("mkdocs") -global_config = {} - - -class MkAPIConfig(Config): - """Specify the config schema.""" - - src_dirs = config_options.Type(list, default=[]) - on_config = config_options.Type(str, default="") - filters = config_options.Type(list, default=[]) - callback = config_options.Type(str, default="") - abs_api_paths = config_options.Type(list, default=[]) - pages = config_options.Type(dict, default={}) - - -class MkAPIPlugin(BasePlugin[MkAPIConfig]): - """MkAPIPlugin class for API generation.""" - - server = None - - def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002 - """Insert `src_dirs` to `sys.path`.""" - insert_sys_path(self.config) - update_config(config, self) - if "admonition" not in config.markdown_extensions: - config.markdown_extensions.append("admonition") - return on_config_plugin(config, self) - - def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 - """Collect plugin CSS/JavaScript and appends them to `files`.""" - root = Path(mkapi.__file__).parent / "theme" - docs_dir = config.docs_dir - config.docs_dir = root.as_posix() - theme_files = get_files(config) - config.docs_dir = docs_dir - theme_name = config.theme.name or "mkdocs" - - css = [] - js = [] - for file in theme_files: - path = Path(file.src_path).as_posix() - if path.endswith(".css"): - if "common" in path or theme_name in path: - files.append(file) - css.append(path) - elif path.endswith(".js"): - files.append(file) - js.append(path) - elif path.endswith(".yml"): - with (root / path).open() as f: - data = yaml.safe_load(f) - css = data.get("extra_css", []) + css - js = data.get("extra_javascript", []) + js - css = [x for x in css if x not in config.extra_css] - js = [x for x in js if x not in config.extra_javascript] - config.extra_css.extend(css) - config.extra_javascript.extend(js) - - return files - - def on_page_markdown( - self, - markdown: str, - page: MkDocsPage, - config: MkDocsConfig, # noqa: ARG002 - files: Files, # noqa: ARG002 - **kwargs, # noqa: ARG002 - ) -> str: - """Convert Markdown source to intermidiate version.""" - abs_src_path = page.file.abs_src_path - clean_page_title(page) - abs_api_paths = self.config.abs_api_paths - filters = self.config.filters - mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) - self.config.pages[abs_src_path] = mkapi_page - return mkapi_page.markdown - - def on_page_content( - self, - html: str, - page: MkDocsPage, - config: MkDocsConfig, # noqa: ARG002 - files: Files, # noqa: ARG002 - **kwargs, # noqa: ARG002 - ) -> str: - """Merge HTML and MkAPI's node structure.""" - if page.title: - page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 - abs_src_path = page.file.abs_src_path - mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - return mkapi_page.content(html) - - def on_page_context( - self, - context: TemplateContext, - page: MkDocsPage, - config: MkDocsConfig, # noqa: ARG002 - nav: Navigation, # noqa: ARG002 - **kwargs, # noqa: ARG002 - ) -> TemplateContext: - """Clear prefix in toc.""" - abs_src_path = page.file.abs_src_path - if abs_src_path in self.config.abs_api_paths: - clear_prefix(page.toc, 2) - else: - mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - for level, id_ in mkapi_page.headings: - clear_prefix(page.toc, level, id_) - return context - - def on_serve( # noqa: D102 - self, - server: LiveReloadServer, - config: MkDocsConfig, # noqa: ARG002 - builder: Callable, - **kwargs, # noqa: ARG002 - ) -> LiveReloadServer: - for path in ["theme", "templates"]: - path_str = (Path(mkapi.__file__).parent / path).as_posix() - server.watch(path_str, builder) - self.__class__.server = server - return server - - -def insert_sys_path(config: MkAPIConfig) -> None: # noqa: D103 - config_dir = Path(config.config_file_path).parent - for src_dir in config.src_dirs: - if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: - sys.path.insert(0, path) - if not config.src_dirs and (path := Path.cwd()) not in sys.path: - sys.path.insert(0, str(path)) - - -def is_api_entry(item: str | list | dict) -> TypeGuard[str]: # noqa:D103 - return isinstance(item, str) and item.lower().startswith("mkapi/") - - -def _walk_nav(nav: list | dict, create_api_nav: Callable[[str], list]) -> None: - it = enumerate(nav) if isinstance(nav, list) else nav.items() - for k, item in it: - if is_api_entry(item): - api_nav = create_api_nav(item) - nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} - elif isinstance(item, list | dict): - _walk_nav(item, create_api_nav) - - -def update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: - """Update nav.""" - if not isinstance(config.nav, list): - return [] - - def create_api_nav(item: str) -> list: - nav, paths = collect(item, config.docs_dir, filters) - abs_api_paths.extend(paths) - return nav - - abs_api_paths: list[Path] = [] - _walk_nav(config.nav, create_api_nav) - - return abs_api_paths - - -def collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[Path]]: - """Collect modules.""" - _, *api_paths, package_path = item.split("/") - api_path = Path(*api_paths) - abs_api_path = Path(docs_dir) / api_path - Path.mkdir(abs_api_path / "source", parents=True, exist_ok=True) - atexit.register(lambda path=abs_api_path: rmtree(path)) - package_path, filters_ = split_filters(package_path) - filters = update_filters(filters, filters_) - - def add_module(module: Module, package: str | None) -> None: - module_path = module.object.id + ".md" - abs_module_path = abs_api_path / module_path - abs_api_paths.append(abs_module_path) - create_page(abs_module_path, module, filters) - module_name = module.object.id - if package and "short_nav" in filters and module_name != package: - module_name = module_name[len(package) + 1 :] - modules[module_name] = (Path(api_path) / module_path).as_posix() - abs_source_path = abs_api_path / "source" / module_path - create_source_page(abs_source_path, module, filters) - - abs_api_paths: list[Path] = [] - modules: dict[str, str] = {} - nav, package = [], None - for module in get_module(package_path): - if module.object.kind == "package": - if package and modules: - nav.append({package: modules}) - package = module.object.id - modules.clear() - if module.docstring or any(m.docstring for m in module.members): - add_module(module, package) - else: - add_module(module, package) - if package and modules: - nav.append({package: modules}) - - return nav, abs_api_paths - - -def update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: D103 - if not plugin.server: - plugin.config.abs_api_paths = update_nav(config, plugin.config.filters) - global_config["nav"] = config.nav - global_config["abs_api_paths"] = plugin.config.abs_api_paths - else: - config.nav = global_config["nav"] - plugin.config.abs_api_paths = global_config["abs_api_paths"] - - -def on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: # noqa: D103 - if plugin.config.on_config: - on_config = get_object(plugin.config.on_config) - kwargs, params = {}, inspect.signature(on_config).parameters - if "config" in params: - kwargs["config"] = config - if "plugin" in params: - kwargs["plugin"] = plugin - msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" - logger.info(msg) - config_ = on_config(**kwargs) - if isinstance(config_, MkDocsConfig): - return config_ - return config - - -def create_page(path: Path, module: Module, filters: list[str]) -> None: - """Create a page.""" - with path.open("w") as f: - f.write(module.get_markdown(filters)) - - -def create_source_page(path: Path, module: Module, filters: list[str]) -> None: - """Create a page for source.""" - filters_str = "|".join(filters) - with path.open("w") as f: - f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") - - -def clear_prefix( - toc: TableOfContents | list[AnchorLink], - level: int, - id_: str = "", -) -> None: - """Clear prefix.""" - for toc_item in toc: - if toc_item.level >= level and (not id_ or toc_item.title == id_): - toc_item.title = toc_item.title.split(".")[-1] - clear_prefix(toc_item.children, level) - - -def clean_page_title(page: MkDocsPage) -> None: - """Clean page title.""" - title = str(page.title) - if title.startswith("![mkapi]("): - page.title = title[9:-1].split("|")[0] # type: ignore # noqa: PGH003 - - -def rmtree(path: Path) -> None: - """Delete directory created by MkAPI.""" - if not path.exists(): - return - try: - shutil.rmtree(path) - except PermissionError: - msg = f"[MkAPI] Couldn't delete directory: {path}" - logger.warning(msg) diff --git a/tests/ast/test_args.py b/tests/ast/test_args.py index e6ca5985..e1d78111 100644 --- a/tests/ast/test_args.py +++ b/tests/ast/test_args.py @@ -1,7 +1,7 @@ import ast from inspect import Parameter -from mkapi.ast.node import get_arguments +from mkapi.ast import get_arguments def _get_args(source: str): diff --git a/tests/ast/test_attrs.py b/tests/ast/test_attrs.py index 32c8d5ef..5a38c1ed 100644 --- a/tests/ast/test_attrs.py +++ b/tests/ast/test_attrs.py @@ -1,6 +1,6 @@ import ast -from mkapi.ast.node import get_attributes +from mkapi.ast import get_attributes def _get_attributes(source: str): diff --git a/tests/ast/test_defs.py b/tests/ast/test_defs.py index 8a84cfc2..3b2a21fb 100644 --- a/tests/ast/test_defs.py +++ b/tests/ast/test_defs.py @@ -1,6 +1,6 @@ import ast -from mkapi.ast.node import get_module +from mkapi.ast import get_module def _get(src: str): diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 18f92244..86e892db 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -2,8 +2,13 @@ import pytest -from mkapi.ast.eval import StringTransformer, iter_identifiers -from mkapi.ast.node import Module, get_module, get_module_node +from mkapi.ast import ( + Module, + StringTransformer, + get_module, + get_module_node, + iter_identifiers, +) def _unparse(src: str) -> str: @@ -33,7 +38,7 @@ def test_parse_expr_str(): @pytest.fixture(scope="module") def module(): - node = get_module_node("mkapi.ast.node") + node = get_module_node("mkapi.ast") return get_module(node) @@ -59,10 +64,14 @@ def test_iter_identifiers(): assert x[1] == ("α.β.γ", True) # noqa: RUF001 -# def test_functions(module: Module): -# func = module.functions._get_def_args # noqa: SLF001 -# ann = func.args[0].annotation -# assert isinstance(ann, ast.expr) -# print(Transformer().unparse(ann)) -# print(Transformer().unparse(func.returns)) -# assert 0 +def test_functions(module: Module): + func = module.functions._get_def_args # noqa: SLF001 + ann = func.arguments[0].annotation + assert isinstance(ann, ast.expr) + text = StringTransformer().unparse(ann) + for s in iter_identifiers(text): + print(s) + print(module.imports) + print(module.classes) + print(module.attributes) + print(module.functions) diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index b58aff69..11becb24 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -3,7 +3,7 @@ import pytest -from mkapi.ast.node import ( +from mkapi.ast import ( get_module, get_module_node, iter_definition_nodes, @@ -59,10 +59,10 @@ def test_iter_definition_nodes(def_nodes): def test_get_module(): - node = get_module_node("mkapi.ast.node") + node = get_module_node("mkapi.ast") module = get_module(node) assert module.docstring assert "_ParameterKind" in module.imports - assert module.attrs.Assign_ + assert module.attributes.Assign_ assert module.classes.Module assert module.functions.get_module diff --git a/tests_old/core/test_core_docstring_from_text.py b/tests/test_docstring.py similarity index 59% rename from tests_old/core/test_core_docstring_from_text.py rename to tests/test_docstring.py index 1114b6e4..53f0bd8e 100644 --- a/tests_old/core/test_core_docstring_from_text.py +++ b/tests/test_docstring.py @@ -1,12 +1,70 @@ import pytest -from mkapi.core.docstring import (parse_parameter, parse_returns, - split_parameter, split_section) +from mkapi.docstring import ( + Base, + Docstring, + Inline, + Item, + Section, + Type, + _rename_section, + parse_parameter, + parse_returns, + split_parameter, + split_section, +) + + +def test_update_item(): + a = Item("a", Type("int"), Inline("aaa")) + b = Item("b", Type("str"), Inline("bbb")) + with pytest.raises(ValueError): # noqa: PT011 + a.update(b) + + +def test_section_delete_item(): + a = Item("a", Type("int"), Inline("aaa")) + b = Item("b", Type("str"), Inline("bbb")) + c = Item("c", Type("float"), Inline("ccc")) + s = Section("Parameters", items=[a, b, c]) + del s["b"] + assert "b" not in s + with pytest.raises(KeyError): + del s["x"] + + +def test_section_merge(): + a = Section("a") + b = Section("b") + with pytest.raises(ValueError): # noqa: PT011 + a.merge(b) + + +def test_docstring_copy(): + d = Docstring() + a = Section("Parameters") + d.set_section(a) + assert "Parameters" in d + assert d["Parameters"] is a + a = Section("Arguments") + d.set_section(a, copy=True) + assert "Arguments" in d + assert d["Arguments"] is not a + + +def test_copy(): + x = Base("x", "markdown") + y = x.copy() + assert y.name == "x" + assert y.markdown == "markdown" + + +def test_rename_section(): + assert _rename_section("Warns") == "Warnings" + doc = {} -doc[ - "google" -] = """a +doc["google"] = """a b @@ -34,9 +92,7 @@ f """ -doc[ - "numpy" -] = """a +doc["numpy"] = """a b @@ -122,6 +178,6 @@ def test_parse_returns(style): next(it) next(it) section, body, style = next(it) - type, markdown = parse_returns(body, style) + type_, markdown = parse_returns(body, style) assert markdown == "e\n\nf" - assert type == "int" + assert type_ == "int" diff --git a/tests/test_inspect.py b/tests/test_inspect.py deleted file mode 100644 index 03ff7950..00000000 --- a/tests/test_inspect.py +++ /dev/null @@ -1,12 +0,0 @@ -# import ast - -# import pytest - -# from mkapi.modules import get_module - - -# def test_(): -# module = get_module("mkdocs.commands.build") -# names = module.get_names() -# node = module.get_node("get_context") -# print(ast.unparse(node)) diff --git a/tests/test_parser.py b/tests/test_parser.py deleted file mode 100644 index 6edc9b86..00000000 --- a/tests/test_parser.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_parser(): - pass diff --git a/tests/test_utils.py b/tests/test_utils.py index a28b2457..2558b009 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,50 @@ -from mkapi.utils import find_submodule_names +from mkapi.utils import add_admonition, add_fence, find_submodule_names def test_find_submodule_names(): names = find_submodule_names("mkdocs") assert "mkdocs.commands" in names assert "mkdocs.plugins" in names + + +source = """ABC + +>>> 1 + 2 +3 + +DEF + +>>> 3 + 4 +7 + +GHI +""" + +output = """ABC + +~~~python +>>> 1 + 2 +3 +~~~ + +DEF + +~~~python +>>> 3 + 4 +7 +~~~ + +GHI""" + + +def test_add_fence(): + assert add_fence(source) == output + + +def test_add_admonition(): + markdown = add_admonition("Warnings", "abc\n\ndef") + assert markdown == '!!! warning "Warnings"\n abc\n\n def' + markdown = add_admonition("Note", "abc\n\ndef") + assert markdown == '!!! note "Note"\n abc\n\n def' + markdown = add_admonition("Tips", "abc\n\ndef") + assert markdown == '!!! tips "Tips"\n abc\n\n def' diff --git a/tests_old/__init__.py b/tests_old/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests_old/conftest.py b/tests_old/conftest.py deleted file mode 100644 index b28d85b7..00000000 --- a/tests_old/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -import examples.styles.google as example - - -@pytest.fixture(scope="session") -def module(): - return example - - -@pytest.fixture(scope="session") -def add(): - return example.add - - -@pytest.fixture(scope="session") -def gen(): - return example.gen - - -@pytest.fixture(scope="session") -def ExampleClass(): # noqa: N802 - return example.ExampleClass - - -@pytest.fixture(scope="session") -def ExampleDataClass(): # noqa: N802 - return example.ExampleDataClass diff --git a/tests_old/core/test_core_base.py b/tests_old/core/test_core_base.py deleted file mode 100644 index e8e05a46..00000000 --- a/tests_old/core/test_core_base.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from mkapi.core.base import Base, Docstring, Item, Section - - -def test_update_item(): - a = Item("a", "int", "aaa") - b = Item("b", "str", "bbb") - with pytest.raises(ValueError): - a.update(b) - - -def test_section_delete_item(): - a = Item("a", "int", "aaa") - b = Item("b", "str", "bbb") - c = Item("c", "float", "ccc") - s = Section("Parameters", items=[a, b, c]) - del s["b"] - assert "b" not in s - with pytest.raises(KeyError): - del s["x"] - - -def test_section_merge(): - a = Section("a") - b = Section("b") - with pytest.raises(ValueError): - a.merge(b) - - -def test_docstring_copy(): - d = Docstring() - a = Section("Parameters") - d.set_section(a) - assert "Parameters" in d - assert d["Parameters"] is a - a = Section("Arguments") - d.set_section(a, copy=True) - assert "Arguments" in d - assert d["Arguments"] is not a - - -def test_copy(): - x = Base("x", 'markdown') - y = x.copy() - assert y.name == 'x' - assert y.markdown == 'markdown' diff --git a/tests_old/core/test_core_docstring.py b/tests_old/core/test_core_docstring.py deleted file mode 100644 index 35750bd7..00000000 --- a/tests_old/core/test_core_docstring.py +++ /dev/null @@ -1,25 +0,0 @@ -from mkapi.core.docstring import get_docstring, rename_section - - -def test_function(add): - doc = get_docstring(add) - assert len(doc.sections) == 5 - assert doc.sections[0].name == "" - assert doc.sections[0].markdown.startswith("Returns $") - assert doc.sections[1].name == "Parameters" - assert doc.sections[1].items[0].name == "x" - assert doc.sections[1].items[0].type.name == "int" - assert doc.sections[1].items[1].name == "y" - assert doc.sections[1].items[1].type.name == "int, optional" - - -def test_rename_section(): - assert rename_section('Warns') == 'Warnings' - - -def test_attributes(): - from mkapi.core.base import Base - doc = get_docstring(Base) - for item in doc['Attributes'].items[:3]: - assert item.type.name == 'str' - assert item.description diff --git a/tests_old/core/test_core_preprocess.py b/tests_old/core/test_core_preprocess.py deleted file mode 100644 index df251fa1..00000000 --- a/tests_old/core/test_core_preprocess.py +++ /dev/null @@ -1,43 +0,0 @@ -from mkapi.core.preprocess import add_admonition, add_fence - -source = """ABC - ->>> 1 + 2 -3 - -DEF - ->>> 3 + 4 -7 - -GHI -""" - -output = """ABC - -~~~python ->>> 1 + 2 -3 -~~~ - -DEF - -~~~python ->>> 3 + 4 -7 -~~~ - -GHI""" - - -def test_add_fence(): - assert add_fence(source) == output - - -def test_add_admonition(): - markdown = add_admonition("Warnings", "abc\n\ndef") - assert markdown == '!!! warning "Warnings"\n abc\n\n def' - markdown = add_admonition("Note", "abc\n\ndef") - assert markdown == '!!! note "Note"\n abc\n\n def' - markdown = add_admonition("Tips", "abc\n\ndef") - assert markdown == '!!! tips "Tips"\n abc\n\n def' diff --git a/tests_old/inspect/test_inspect_attribute.py b/tests_old/inspect/test_inspect_attribute.py deleted file mode 100644 index 656ac4e0..00000000 --- a/tests_old/inspect/test_inspect_attribute.py +++ /dev/null @@ -1,157 +0,0 @@ -from dataclasses import dataclass - -from examples.styles import google -from mkapi.core.base import Base -from mkapi.inspect.attribute import get_attributes, get_description, getsource_dedent -from mkapi.inspect.typing import type_string - - -def test_getsource_dedent(): - src = getsource_dedent(Base) - assert src.startswith("@dataclass\nclass Base:\n") - src = getsource_dedent(google.ExampleClass) - assert src.startswith("class ExampleClass:\n") - - -class A: - def __init__(self): - self.x: int = 1 #: Doc comment *inline* with attribute. - #: list of str: Doc comment *before* attribute, with type specified. - self.y = ["123", "abc"] - self.a = "dummy" - self.z: tuple[list[int], dict[str, list[float]]] = ( - [1, 2, 3], - {"a": [1.2]}, - ) - """Docstring *after* attribute, with type specified. - - Multiple paragraphs are supported.""" - - -def test_class_attribute(): - attrs = get_attributes(A) - for k, (name, (type_, markdown)) in enumerate(attrs.items()): - assert name == ["x", "y", "a", "z"][k] - assert markdown.startswith(["Doc ", "list of", "", "Docstring *after*"][k]) - assert markdown.endswith(["attribute.", "specified.", "", "supported."][k]) - if k == 0: - assert type_ == "int" - if k == 1: - assert type_ is None - if k == 2: - assert not markdown - if k == 3: - x = type_string(type_) - assert x == "tuple[list[int], dict[str, list[float]]]" - assert ".\n\nM" in markdown - - -class B: - def __init__(self): - self.x: int = 1 - self.y = ["123", "abc"] - - -def test_class_attribute_without_desc(): - attrs = get_attributes(B) - for k, (name, (_, markdown)) in enumerate(attrs.items()): - assert name == ["x", "y"][k] - assert markdown == "" - - -@dataclass -class C: - x: int #: int - #: A - y: A - z: B - """B - - end. - """ - - def func(self): - pass - - -def test_dataclass_attribute(): - attrs = get_attributes(C) - for k, (name, (type_, markdown)) in enumerate(attrs.items()): - assert name == ["x", "y", "z"][k] - assert markdown == ["int", "A", "B\n\nend."][k] - if k == 0: - assert type_ is int - - -def test_dataclass_ast_parse(): - import ast - import inspect - - x = C - s = inspect.getsource(x) - print(s) - n = ast.parse(s) - print(n) - assert 0 - - -@dataclass -class D: - x: int - y: list[str] - - -def test_dataclass_attribute_without_desc(): - attrs = get_attributes(D) - for k, (name, (type_, markdown)) in enumerate(attrs.items()): - assert name == ["x", "y"][k] - assert markdown == "" - if k == 0: - assert type_ is int - if k == 1: - x = type_string(type_) - assert x == "list[str]" - - -def test_module_attribute(): - attrs = get_attributes(google) # type:ignore - for k, (name, (type_, markdown)) in enumerate(attrs.items()): - if k == 0: - assert name == "first_attribute" - assert type_ == "int" - assert markdown.startswith("The first module level attribute.") - if k == 1: - assert name == "second_attribute" - assert type_ is None - assert markdown.startswith("str: The second module level attribute.") - if k == 2: - assert name == "third_attribute" - assert type_string(type_) == "list[int]" - assert markdown.startswith("The third module level attribute.") - assert markdown.endswith("supported.") - - -def test_one_line_docstring(): - lines = ["x = 1", "'''docstring'''"] - assert get_description(lines, 1) == "docstring" - - -def test_module_attribute_tye(): - from mkapi.core import renderer - - assert get_attributes(renderer)["renderer"][0] is renderer.Renderer # type: ignore - - -class E: - def __init__(self): - self.a: int = 0 #: attr-a - self.b: "E" = self #: attr-b - - def func(self): - pass - - -def test_multiple_assignments(): - attrs = get_attributes(E) - assert attrs["a"] == ("int", "attr-a") - assert attrs["b"] == (E, "attr-b") diff --git a/tests_old/inspect/test_inspect_signature.py b/tests_old/inspect/test_inspect_signature.py deleted file mode 100644 index 901e4f7f..00000000 --- a/tests_old/inspect/test_inspect_signature.py +++ /dev/null @@ -1,69 +0,0 @@ -import inspect - -import pytest - -from mkapi.core.node import Node -from mkapi.inspect.signature import Signature, get_parameters, get_signature - - -def test_get_parameters_error(): - with pytest.raises(TypeError): - get_parameters(1) - - -def test_get_parameters_function(add): - parameters, defaults = get_parameters(add) - assert len(parameters) == 2 - x, y = parameters - assert x.name == "x" - assert x.type.name == "int" - assert y.name == "y" - assert y.type.name == "int, optional" - assert defaults["x"] is inspect.Parameter.empty - assert defaults["y"] == "1" - - -def test_function(add): - s = Signature(add) - assert str(s) == "(x, y=1)" - - assert "x" in s - assert s.parameters["x"].to_tuple()[1] == "int" - assert s.parameters["y"].to_tuple()[1] == "int, optional" - assert s.returns == "int" - - -def test_generator(gen): - s = Signature(gen) - assert "n" in s - # assert s.parameters["n"].to_tuple()[1] == "" - assert s.yields == "str" - - -def test_class(ExampleClass): # noqa: N803 - s = Signature(ExampleClass) - assert s.parameters["x"].to_tuple()[1] == "list[int]" - # assert s.parameters["y"].to_tuple()[1] == "(str, int)" - - -def test_dataclass(ExampleDataClass): # noqa: N803 - s = Signature(ExampleDataClass) - assert s.attributes["x"].to_tuple()[1] == "int" - assert s.attributes["y"].to_tuple()[1] == "int" - - -def test_var(): - def func(x, *args, **kwargs): - return x, args, kwargs - - s = get_signature(func) - assert s.parameters.items[1].name == "*args" - assert s.parameters.items[2].name == "**kwargs" - - -def test_get_signature_special(): - s = Signature(Node.__getitem__) - assert s.parameters.items[0].name == "index" - assert s.returns == "[Self](!typing.Self)" - t = "int | str | list[str]" - assert s.parameters["index"].to_tuple() == ("index", t, "") diff --git a/tests_old/inspect/test_inspect_signature_typecheck.py b/tests_old/inspect/test_inspect_signature_typecheck.py deleted file mode 100644 index 8aa9641a..00000000 --- a/tests_old/inspect/test_inspect_signature_typecheck.py +++ /dev/null @@ -1,13 +0,0 @@ -import inspect - -from examples.typing import current, future - - -def test_current_annotation(): - signature = inspect.signature(current.func) - assert signature.parameters["x"].annotation is int - - -def test_future_annotation(): - signature = inspect.signature(future.func) - assert signature.parameters["x"].annotation == "int" diff --git a/tests_old/inspect/test_inspect_typing.py b/tests_old/inspect/test_inspect_typing.py deleted file mode 100644 index 6ff77326..00000000 --- a/tests_old/inspect/test_inspect_typing.py +++ /dev/null @@ -1,14 +0,0 @@ -from collections.abc import Callable -from typing import Self - -from mkapi.inspect.signature import Signature -from mkapi.inspect.typing import type_string - - -def test_type_string(): - assert type_string(list) == "list" - assert type_string(tuple) == "()" - assert type_string(dict) == "dict" - assert type_string(Callable) == "[Callable](!collections.abc.Callable)" - assert type_string(Signature) == "[Signature](!mkapi.inspect.signature.Signature)" - assert type_string(Self) == "[Self](!typing.Self)" diff --git a/tests_old/plugins/test_plugins_mkdocs.py b/tests_old/plugins/test_plugins_mkdocs.py deleted file mode 100644 index d75069fe..00000000 --- a/tests_old/plugins/test_plugins_mkdocs.py +++ /dev/null @@ -1,83 +0,0 @@ -from pathlib import Path - -import pytest -from jinja2.environment import Environment -from mkdocs.commands.build import build -from mkdocs.config import load_config -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.plugins import PluginCollection -from mkdocs.theme import Theme - -from mkapi.plugins.mkdocs import MkAPIConfig, MkAPIPlugin - - -@pytest.fixture(scope="module") -def config_file(): - return Path(__file__).parent.parent.parent / "examples" / "mkdocs.yml" - - -def test_config_file_exists(config_file: Path): - assert config_file.exists() - - -@pytest.fixture(scope="module") -def mkdocs_config(config_file: Path): - return load_config(str(config_file)) - - -def test_mkdocs_config(mkdocs_config: MkDocsConfig): - config = mkdocs_config - assert isinstance(config, MkDocsConfig) - path = Path(config.config_file_path) - assert path.as_posix().endswith("mkapi/examples/mkdocs.yml") - assert config.site_name == "Doc for CI" - assert Path(config.docs_dir) == path.parent / "docs" - assert Path(config.site_dir) == path.parent / "site" - assert config.nav[0] == "index.md" # type: ignore - assert isinstance(config.plugins, PluginCollection) - assert isinstance(config.plugins["mkapi"], MkAPIPlugin) - assert config.pages is None - assert isinstance(config.theme, Theme) - assert config.theme.name == "mkdocs" - assert isinstance(config.theme.get_env(), Environment) - assert config.extra_css == ["custom.css"] - assert str(config.extra_javascript[0]).endswith("tex-mml-chtml.js") - assert "pymdownx.arithmatex" in config.markdown_extensions - - -@pytest.fixture(scope="module") -def mkapi_plugin(mkdocs_config: MkDocsConfig): - return mkdocs_config.plugins["mkapi"] - - -def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): - assert mkapi_plugin.server is None - assert isinstance(mkapi_plugin.config, MkAPIConfig) - - -@pytest.fixture(scope="module") -def mkapi_config(mkapi_plugin: MkAPIPlugin): - return mkapi_plugin.config - - -def test_mkapi_config(mkapi_config: MkAPIConfig): - config = mkapi_config - x = ["src_dirs", "on_config", "filters", "callback", "abs_api_paths", "pages"] - assert list(config) == x - assert config.src_dirs == ["."] - assert config.on_config == "custom.on_config" - - -@pytest.fixture(scope="module") -def env(mkdocs_config: MkDocsConfig): - return mkdocs_config.theme.get_env() - - -def test_mkdocs_build(mkdocs_config: MkDocsConfig): - config = mkdocs_config - config.plugins.on_startup(command="build", dirty=False) - try: - build(config, dirty=False) - assert True - finally: - config.plugins.on_shutdown() From 44ebb2605b21716b63b1a403a276b5d03a88275d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 3 Jan 2024 13:41:39 +0900 Subject: [PATCH 046/148] Argument -> Parameter --- examples/styles/__init__.py | 4 + examples/styles/example_google.py | 314 ++++++++ examples/styles/example_numpy.py | 355 +++++++++ examples/styles/google.py | 117 --- examples/styles/numpy.py | 128 --- src/mkapi/ast.py | 196 +++-- src/mkapi/docstring.py | 1204 +++++++++++------------------ tests/ast/test_args.py | 42 +- tests/ast/test_attrs.py | 10 +- tests/ast/test_eval.py | 6 +- tests/ast/test_node.py | 5 +- tests/conftest.py | 24 + tests/docstring/test_examples.py | 73 ++ tests/test_docstring.py | 183 ----- 14 files changed, 1392 insertions(+), 1269 deletions(-) create mode 100644 examples/styles/example_google.py create mode 100644 examples/styles/example_numpy.py delete mode 100644 examples/styles/google.py delete mode 100644 examples/styles/numpy.py create mode 100644 tests/conftest.py create mode 100644 tests/docstring/test_examples.py delete mode 100644 tests/test_docstring.py diff --git a/examples/styles/__init__.py b/examples/styles/__init__.py index e69de29b..5e75222f 100644 --- a/examples/styles/__init__.py +++ b/examples/styles/__init__.py @@ -0,0 +1,4 @@ +"""" +http://ja.dochub.org/sphinx/usage/extensions/example_google.html#example-google +http://ja.dochub.org/sphinx/usage/extensions/example_numpy.html#example-numpy +""" diff --git a/examples/styles/example_google.py b/examples/styles/example_google.py new file mode 100644 index 00000000..5fde6e22 --- /dev/null +++ b/examples/styles/example_google.py @@ -0,0 +1,314 @@ +"""Example Google style docstrings. + +This module demonstrates documentation as specified by the `Google Python +Style Guide`_. Docstrings may extend over multiple lines. Sections are created +with a section header and a colon followed by a block of indented text. + +Example: + Examples can be given using either the ``Example`` or ``Examples`` + sections. Sections support any reStructuredText formatting, including + literal blocks:: + + $ python example_google.py + +Section breaks are created by resuming unindented text. Section breaks +are also implicitly created anytime a new section starts. + +Attributes: + module_level_variable1 (int): Module level variables may be documented in + either the ``Attributes`` section of the module docstring, or in an + inline docstring immediately following the variable. + + Either form is acceptable, but the two should not be mixed. Choose + one convention to document module level variables and be consistent + with it. + +Todo: + * For module TODOs + * You have to also use ``sphinx.ext.todo`` extension + +.. _Google Python Style Guide: + https://google.github.io/styleguide/pyguide.html + +""" + +module_level_variable1 = 12345 + +module_level_variable2 = 98765 +"""int: Module level variable documented inline. + +The docstring may span multiple lines. The type may optionally be specified +on the first line, separated by a colon. +""" + + +def function_with_types_in_docstring(param1, param2): + """Example function with types documented in the docstring. + + `PEP 484`_ type annotations are supported. If attribute, parameter, and + return types are annotated according to `PEP 484`_, they do not need to be + included in the docstring: + + Args: + param1 (int): The first parameter. + param2 (str): The second parameter. + + Returns: + bool: The return value. True for success, False otherwise. + + .. _PEP 484: + https://www.python.org/dev/peps/pep-0484/ + + """ + + +def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: + """Example function with PEP 484 type annotations. + + Args: + param1: The first parameter. + param2: The second parameter. + + Returns: + The return value. True for success, False otherwise. + + """ + + +def module_level_function(param1, param2=None, *args, **kwargs): + """This is an example of a module level function. + + Function parameters should be documented in the ``Args`` section. The name + of each parameter is required. The type and description of each parameter + is optional, but should be included if not obvious. + + If ``*args`` or ``**kwargs`` are accepted, + they should be listed as ``*args`` and ``**kwargs``. + + The format for a parameter is:: + + name (type): description + The description may span multiple lines. Following + lines should be indented. The "(type)" is optional. + + Multiple paragraphs are supported in parameter + descriptions. + + Args: + param1 (int): The first parameter. + param2 (:obj:`str`, optional): The second parameter. Defaults to None. + Second line of description should be indented. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + bool: True if successful, False otherwise. + + The return type is optional and may be specified at the beginning of + the ``Returns`` section followed by a colon. + + The ``Returns`` section may span multiple lines and paragraphs. + Following lines should be indented to match the first line. + + The ``Returns`` section supports any reStructuredText formatting, + including literal blocks:: + + { + 'param1': param1, + 'param2': param2 + } + + Raises: + AttributeError: The ``Raises`` section is a list of all exceptions + that are relevant to the interface. + ValueError: If `param2` is equal to `param1`. + + """ + if param1 == param2: + raise ValueError('param1 may not be equal to param2') + return True + + +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Args: + n (int): The upper limit of the range to generate, from 0 to `n` - 1. + + Yields: + int: The next number in the range of 0 to `n` - 1. + + Examples: + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + for i in range(n): + yield i + + +class ExampleError(Exception): + """Exceptions are documented in the same way as classes. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + msg (str): Human readable string describing the exception. + code (:obj:`int`, optional): Error code. + + Attributes: + msg (str): Human readable string describing the exception. + code (int): Exception error code. + + """ + + def __init__(self, msg, code): + self.msg = msg + self.code = code + + +class ExampleClass: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. Alternatively, attributes may be documented + inline with the attribute's declaration (see __init__ method below). + + Properties created with the ``@property`` decorator should be documented + in the property's getter method. + + Attributes: + attr1 (str): Description of `attr1`. + attr2 (:obj:`int`, optional): Description of `attr2`. + + """ + + def __init__(self, param1, param2, param3): + """Example of docstring on the __init__ method. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + param1 (str): Description of `param1`. + param2 (:obj:`int`, optional): Description of `param2`. Multiple + lines are supported. + param3 (list(str)): Description of `param3`. + + """ + self.attr1 = param1 + self.attr2 = param2 + self.attr3 = param3 #: Doc comment *inline* with attribute + + #: list(str): Doc comment *before* attribute, with type specified + self.attr4 = ['attr4'] + + self.attr5 = None + """str: Docstring *after* attribute, with type specified.""" + + @property + def readonly_property(self): + """str: Properties should be documented in their getter method.""" + return 'readonly_property' + + @property + def readwrite_property(self): + """list(str): Properties with both a getter and setter + should only be documented in their getter method. + + If the setter method contains notable behavior, it should be + mentioned here. + """ + return ['readwrite_property'] + + @readwrite_property.setter + def readwrite_property(self, value): + value + + def example_method(self, param1, param2): + """Class methods are similar to regular functions. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + param1: The first parameter. + param2: The second parameter. + + Returns: + True if successful, False otherwise. + + """ + return True + + def __special__(self): + """By default special members with docstrings are not included. + + Special members are any methods or attributes that start with and + end with a double underscore. Any special member with a docstring + will be included in the output, if + ``napoleon_include_special_with_doc`` is set to True. + + This behavior can be enabled by changing the following setting in + Sphinx's conf.py:: + + napoleon_include_special_with_doc = True + + """ + pass + + def __special_without_docstring__(self): + pass + + def _private(self): + """By default private members are not included. + + Private members are any methods or attributes that start with an + underscore and are *not* special. By default they are not included + in the output. + + This behavior can be changed such that private members *are* included + by changing the following setting in Sphinx's conf.py:: + + napoleon_include_private_with_doc = True + + """ + pass + + def _private_without_docstring(self): + pass + +class ExamplePEP526Class: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. If ``napoleon_attr_annotations`` + is True, types can be specified in the class body using ``PEP 526`` + annotations. + + Attributes: + attr1: Description of `attr1`. + attr2: Description of `attr2`. + + """ + + attr1: str + attr2: int \ No newline at end of file diff --git a/examples/styles/example_numpy.py b/examples/styles/example_numpy.py new file mode 100644 index 00000000..2712447f --- /dev/null +++ b/examples/styles/example_numpy.py @@ -0,0 +1,355 @@ +"""Example NumPy style docstrings. + +This module demonstrates documentation as specified by the `NumPy +Documentation HOWTO`_. Docstrings may extend over multiple lines. Sections +are created with a section header followed by an underline of equal length. + +Example +------- +Examples can be given using either the ``Example`` or ``Examples`` +sections. Sections support any reStructuredText formatting, including +literal blocks:: + + $ python example_numpy.py + + +Section breaks are created with two blank lines. Section breaks are also +implicitly created anytime a new section starts. Section bodies *may* be +indented: + +Notes +----- + This is an example of an indented section. It's like any other section, + but the body is indented to help it stand out from surrounding text. + +If a section is indented, then a section break is created by +resuming unindented text. + +Attributes +---------- +module_level_variable1 : int + Module level variables may be documented in either the ``Attributes`` + section of the module docstring, or in an inline docstring immediately + following the variable. + + Either form is acceptable, but the two should not be mixed. Choose + one convention to document module level variables and be consistent + with it. + + +.. _NumPy Documentation HOWTO: + https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + +module_level_variable1 = 12345 + +module_level_variable2 = 98765 +"""int: Module level variable documented inline. + +The docstring may span multiple lines. The type may optionally be specified +on the first line, separated by a colon. +""" + + +def function_with_types_in_docstring(param1, param2): + """Example function with types documented in the docstring. + + `PEP 484`_ type annotations are supported. If attribute, parameter, and + return types are annotated according to `PEP 484`_, they do not need to be + included in the docstring: + + Parameters + ---------- + param1 : int + The first parameter. + param2 : str + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + .. _PEP 484: + https://www.python.org/dev/peps/pep-0484/ + + """ + + +def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: + """Example function with PEP 484 type annotations. + + The return type must be duplicated in the docstring to comply + with the NumPy docstring style. + + Parameters + ---------- + param1 + The first parameter. + param2 + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + """ + + +def module_level_function(param1, param2=None, *args, **kwargs): + """This is an example of a module level function. + + Function parameters should be documented in the ``Parameters`` section. + The name of each parameter is required. The type and description of each + parameter is optional, but should be included if not obvious. + + If ``*args`` or ``**kwargs`` are accepted, + they should be listed as ``*args`` and ``**kwargs``. + + The format for a parameter is:: + + name : type + description + + The description may span multiple lines. Following lines + should be indented to match the first line of the description. + The ": type" is optional. + + Multiple paragraphs are supported in parameter + descriptions. + + Parameters + ---------- + param1 : int + The first parameter. + param2 : :obj:`str`, optional + The second parameter. + *args + Variable length argument list. + **kwargs + Arbitrary keyword arguments. + + Returns + ------- + bool + True if successful, False otherwise. + + The return type is not optional. The ``Returns`` section may span + multiple lines and paragraphs. Following lines should be indented to + match the first line of the description. + + The ``Returns`` section supports any reStructuredText formatting, + including literal blocks:: + + { + 'param1': param1, + 'param2': param2 + } + + Raises + ------ + AttributeError + The ``Raises`` section is a list of all exceptions + that are relevant to the interface. + ValueError + If `param2` is equal to `param1`. + + """ + if param1 == param2: + raise ValueError('param1 may not be equal to param2') + return True + + +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Parameters + ---------- + n : int + The upper limit of the range to generate, from 0 to `n` - 1. + + Yields + ------ + int + The next number in the range of 0 to `n` - 1. + + Examples + -------- + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + for i in range(n): + yield i + + +class ExampleError(Exception): + """Exceptions are documented in the same way as classes. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + msg : str + Human readable string describing the exception. + code : :obj:`int`, optional + Numeric error code. + + Attributes + ---------- + msg : str + Human readable string describing the exception. + code : int + Numeric error code. + + """ + + def __init__(self, msg, code): + self.msg = msg + self.code = code + + +class ExampleClass: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. Alternatively, attributes may be documented + inline with the attribute's declaration (see __init__ method below). + + Properties created with the ``@property`` decorator should be documented + in the property's getter method. + + Attributes + ---------- + attr1 : str + Description of `attr1`. + attr2 : :obj:`int`, optional + Description of `attr2`. + + """ + + def __init__(self, param1, param2, param3): + """Example of docstring on the __init__ method. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + param1 : str + Description of `param1`. + param2 : list(str) + Description of `param2`. Multiple + lines are supported. + param3 : :obj:`int`, optional + Description of `param3`. + + """ + self.attr1 = param1 + self.attr2 = param2 + self.attr3 = param3 #: Doc comment *inline* with attribute + + #: list(str): Doc comment *before* attribute, with type specified + self.attr4 = ["attr4"] + + self.attr5 = None + """str: Docstring *after* attribute, with type specified.""" + + @property + def readonly_property(self): + """str: Properties should be documented in their getter method.""" + return "readonly_property" + + @property + def readwrite_property(self): + """list(str): Properties with both a getter and setter + should only be documented in their getter method. + + If the setter method contains notable behavior, it should be + mentioned here. + """ + return ["readwrite_property"] + + @readwrite_property.setter + def readwrite_property(self, value): + value + + def example_method(self, param1, param2): + """Class methods are similar to regular functions. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + param1 + The first parameter. + param2 + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + """ + return True + + def __special__(self): + """By default special members with docstrings are not included. + + Special members are any methods or attributes that start with and + end with a double underscore. Any special member with a docstring + will be included in the output, if + ``napoleon_include_special_with_doc`` is set to True. + + This behavior can be enabled by changing the following setting in + Sphinx's conf.py:: + + napoleon_include_special_with_doc = True + + """ + pass + + def __special_without_docstring__(self): + pass + + def _private(self): + """By default private members are not included. + + Private members are any methods or attributes that start with an + underscore and are *not* special. By default they are not included + in the output. + + This behavior can be changed such that private members *are* included + by changing the following setting in Sphinx's conf.py:: + + napoleon_include_private_with_doc = True + + """ + pass + + def _private_without_docstring(self): + pass diff --git a/examples/styles/google.py b/examples/styles/google.py deleted file mode 100644 index 62e2bac8..00000000 --- a/examples/styles/google.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Module level docstring.""" -from collections.abc import Iterator -from dataclasses import dataclass, field - -#: The first module level attribute. Comment *before* attribute. -first_attribute: int = 1 -second_attribute = "abc" #: str: The second module level attribute. *Inline* style. -third_attribute: list[int] = [1, 2, 3] -"""The third module level attribute. Docstring *after* attribute. - -Multiple paragraphs are supported. -""" -not_attribute = 123 # Not attribute description because ':' is missing. - - -def add(x: int, y: int = 1) -> int: - """Returns $x + y$. - - Args: - x: The first parameter. - y: The second parameter. Default={default}. - - Returns: - Added value. - - Examples: - Examples should be written in doctest format. - - >>> add(1, 2) - 3 - - !!! note - You can use the [Admonition extension of - MkDocs](https://squidfunk.github.io/mkdocs-material/extensions/admonition/). - - Note: - `Note` section is converted into the Admonition. - """ - return x + y - - -def gen(n) -> Iterator[str]: - """Yields a numbered string. - - Args: - n (int): The length of iteration. - - Yields: - A numbered string. - """ - for x in range(n): - yield f"a{x}" - - -class ExampleClass: - """A normal class. - - Args: - x: The first parameter. - y: The second parameter. - - Raises: - ValueError: If the length of `x` is equal to 0. - """ - - e: str - """dde""" - - def __init__(self, x: list[int], y: tuple[str, int]): - if len(x) == 0 or y[1] == 0: - raise ValueError - self.a: str = "abc" #: The first attribute. Comment *inline* with attribute. - #: The second attribute. Comment *before* attribute. - self.b: dict[str, int] = {"a": 1} - self.c = None - """int, optional: The third attribute. Docstring *after* attribute. - - Multiple paragraphs are supported.""" - self.d = 100 # Not attribute description because ':' is missing. - - def message(self, n: int) -> list[str]: - """Returns a message list. - - Args: - n: Repetition. - """ - return [self.a] * n - - @property - def readonly_property(self): - """str: Read-only property documentation.""" - return "readonly property" - - @property - def readwrite_property(self) -> list[int]: - """Read-write property documentation.""" - return [1, 2, 3] - - @readwrite_property.setter - def readwrite_property(self, value): - """Docstring in setter is ignored.""" - - -@dataclass -class ExampleDataClass: - """A dataclass. - - Args: - x: The first parameter. - - Attributes: - x: The first attribute. - y: The second attribute. - """ - - x: int = 0 - y: int = field(default=1, init=False) diff --git a/examples/styles/numpy.py b/examples/styles/numpy.py deleted file mode 100644 index 7469347b..00000000 --- a/examples/styles/numpy.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Module level docstring.""" -from collections.abc import Iterator -from dataclasses import dataclass, field - - -def add(x: int, y: int = 1) -> int: - """Returns $x + y$. - - Parameters - ---------- - x - The first parameter. - y - The second parameter. Default={default}. - - Returns - ------- - int - Added value. - - !!! note - The return type must be duplicated in the docstring to comply with the NumPy - docstring style. - - Examples - -------- - Examples should be written in doctest format. - - >>> add(1, 2) - 3 - - Note - ---- - MkAPI doesn't check an underline that follows a section heading. - Just skip one line. - """ - return x + y - - -def gen(n) -> Iterator[str]: - """Yields a numbered string. - - Parameters - ---------- - n : int - The length of iteration. - - Yields - ------ - str - A numbered string. - """ - for x in range(n): - yield f"a{x}" - - -class ExampleClass: - """A normal class. - - Parameters - ---------- - x - The first parameter. - y - The second parameter. - - Raises - ------ - ValueError - If the length of `x` is equal to 0. - """ - - def __init__(self, x: list[int], y: tuple[str, int]): - if len(x) == 0 or y[1] == 0: - raise ValueError - self.a: str = "abc" #: The first attribute. Comment *inline* with attribute. - #: The second attribute. Comment *before* attribute. - self.b: dict[str, int] = {"a": 1} - self.c = None - """int, optional: The third attribute. Docstring *after* attribute. - - Multiple paragraphs are supported.""" - self.d = 100 # Not attribute description because ':' is missing. - - def message(self, n: int) -> list[str]: - """Returns a message list. - - Parameters - ---------- - n - Repetition. - """ - return [self.a] * n - - @property - def readonly_property(self): - """str: Read-only property documentation.""" - return "readonly property" - - @property - def readwrite_property(self) -> list[int]: - """Read-write property documentation.""" - return [1, 2, 3] - - @readwrite_property.setter - def readwrite_property(self, value): - """Docstring in setter is ignored.""" - - -@dataclass -class ExampleDataClass: - """A dataclass. - - Parameters - ---------- - x - The first parameter. - - Attributes - ---------- - x - The first attribute. - y - The second attribute. - """ - - x: int = 0 - y: int = field(default=1, init=False) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index a19a6cb4..18c64aef 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -9,14 +9,14 @@ Constant, Expr, FunctionDef, - Import, ImportFrom, Name, TypeAlias, ) from dataclasses import dataclass from importlib.util import find_spec -from inspect import Parameter, cleandoc +from inspect import Parameter as P +from inspect import cleandoc from pathlib import Path from typing import TYPE_CHECKING @@ -51,23 +51,25 @@ def get_module_node(name: str) -> ast.Module: return node -def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: +def iter_import_nodes(node: AST) -> Iterator[ast.Import | ImportFrom]: """Yield import nodes.""" for child in ast.iter_child_nodes(node): - if isinstance(child, Import | ImportFrom): + if isinstance(child, ast.Import | ImportFrom): yield child elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield from iter_import_nodes(child) -def iter_import_names(node: ast.Module | Def) -> Iterator[tuple[str, str]]: - """Yield imported names.""" +def iter_import_node_names( + node: ast.Module | Def, +) -> Iterator[tuple[ast.Import | ImportFrom, str, str]]: + """Yield import nodes and names.""" for child in iter_import_nodes(node): from_module = f"{child.module}." if isinstance(child, ImportFrom) else "" for alias in child.names: name = alias.asname or alias.name fullname = f"{from_module}{alias.name}" - yield name, fullname + yield child, name, fullname def get_assign_name(node: Assign_) -> str | None: @@ -125,15 +127,23 @@ def get_docstring(node: Doc) -> str | None: @dataclass -class _Item: +class Node: + """Base class.""" + + _node: AST name: str + """Name of item.""" + docstring: str | None + """Docstring of item.""" def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name!r})" @dataclass -class _Items[T]: +class Nodes[T]: + """Collection of [Node] instance.""" + items: list[T] def __getitem__(self, index: int | str) -> T: @@ -142,27 +152,54 @@ def __getitem__(self, index: int | str) -> T: return getattr(self, index) def __getattr__(self, name: str) -> T: - names = [elem.name for elem in self.items] # type: ignore # noqa: PGH003 + names = [item.name for item in self.items] # type: ignore # noqa: PGH003 return self.items[names.index(name)] def __iter__(self) -> Iterator[T]: return iter(self.items) + def __contains__(self, name: str) -> bool: + return any(name == item.name for item in self.items) # type: ignore # noqa: PGH003 + def __repr__(self) -> str: - names = ", ".join(f"{elem.name!r}" for elem in self.items) # type: ignore # noqa: PGH003 + names = ", ".join(f"{item.name!r}" for item in self.items) # type: ignore # noqa: PGH003 return f"{self.__class__.__name__}({names})" +@dataclass(repr=False) +class Import(Node): + """Import class.""" + + _node: ast.Import | ImportFrom + fullanme: str + + +@dataclass +class Imports(Nodes[Import]): + """Imports class.""" + + +def iter_imports(node: ast.Module | ClassDef) -> Iterator[Import]: + """Yield import nodes.""" + for child, name, fullname in iter_import_node_names(node): + yield Import(child, name, None, fullname) + + +def get_imports(node: ast.Module | ClassDef) -> Imports: + """Return imports in module or class definition.""" + return Imports(list(iter_imports(node))) + + ARGS_KIND: dict[_ParameterKind, str] = { - Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list - Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list - Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None - Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list - Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None + P.POSITIONAL_ONLY: "posonlyargs", # before '/', list + P.POSITIONAL_OR_KEYWORD: "args", # normal, list + P.VAR_POSITIONAL: "vararg", # *args, arg or None + P.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list + P.VAR_KEYWORD: "kwarg", # **kwargs, arg or None } -def _iter_arguments(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: +def _iter_parameters(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: for kind, attr in ARGS_KIND.items(): if args := getattr(node.args, attr): it = args if isinstance(args, list) else [args] @@ -178,51 +215,52 @@ def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: @dataclass(repr=False) -class Argument(_Item): - """Argument class.""" +class XXXX(Node): + """XXXX class.""" - annotation: ast.expr | None + type: ast.expr | None # # noqa: A003 default: ast.expr | None + + +@dataclass(repr=False) +class Parameter(XXXX): + """Parameter class.""" + + _node: ast.arg kind: _ParameterKind @dataclass -class Arguments(_Items[Argument]): - """Arguments class.""" +class Parameters(Nodes[Parameter]): + """Parameters class.""" -def iter_arguments(node: FunctionDef_) -> Iterator[Argument]: - """Yield arguments from the function node.""" +def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: + """Yield parameters from the function node.""" it = _iter_defaults(node) - for arg, kind in _iter_arguments(node): - if kind in [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]: - default = None - else: - default = next(it) - yield Argument(arg.arg, arg.annotation, default, kind) + for arg, kind in _iter_parameters(node): + default = None if kind in [P.VAR_POSITIONAL, P.VAR_KEYWORD] else next(it) + yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) -def get_arguments(node: FunctionDef_ | ClassDef) -> Arguments: - """Return the function arguments.""" +def get_parameters(node: FunctionDef_ | ClassDef) -> Parameters: + """Return the function parameters.""" if isinstance(node, ClassDef): - return Arguments([]) - return Arguments(list(iter_arguments(node))) + return Parameters([]) + return Parameters(list(iter_parameters(node))) @dataclass(repr=False) -class Attribute(_Item): +class Attribute(XXXX): """Attribute class.""" - annotation: ast.expr | None - value: ast.expr | None - docstring: str | None - kind: type[Assign_] + _node: Assign_ type_params: list[ast.type_param] | None @dataclass(repr=False) -class Attributes(_Items[Attribute]): - """Assigns class.""" +class Attributes(Nodes[Attribute]): + """Attributes class.""" def get_annotation(node: Assign_) -> ast.expr | None: @@ -239,11 +277,11 @@ def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: for assign in iter_assign_nodes(node): if not (name := get_assign_name(assign)): continue - annotation = get_annotation(assign) + ann = get_annotation(assign) value = None if isinstance(assign, TypeAlias) else assign.value - docstring = get_docstring(assign) + doc = get_docstring(assign) type_params = assign.type_params if isinstance(assign, TypeAlias) else None - yield Attribute(name, annotation, value, docstring, type(assign), type_params) + yield Attribute(assign, name, doc, ann, value, type_params) def get_attributes(node: ast.Module | ClassDef) -> Attributes: @@ -252,67 +290,83 @@ def get_attributes(node: ast.Module | ClassDef) -> Attributes: @dataclass(repr=False) -class _Def(_Item): - docstring: str | None - arguments: Arguments +class Definition(Node): + """Definition class for function and class.""" + + parameters: Parameters decorators: list[ast.expr] type_params: list[ast.type_param] + # raises: @dataclass(repr=False) -class Function(_Def): +class Function(Definition): """Function class.""" + _node: FunctionDef_ returns: ast.expr | None - kind: type[FunctionDef_] @dataclass(repr=False) -class Class(_Def): +class Class(Definition): """Class class.""" + _node: ClassDef bases: list[ast.expr] attributes: Attributes + functions: Functions @dataclass(repr=False) -class Functions(_Items[Function]): +class Functions(Nodes[Function]): """Functions class.""" @dataclass(repr=False) -class Classes(_Items[Class]): +class Classes(Nodes[Class]): """Classes class.""" def _get_def_args( node: ClassDef | FunctionDef_, -) -> tuple[str, str | None, Arguments, list[ast.expr], list[ast.type_param]]: +) -> tuple[str, str | None, Parameters, list[ast.expr], list[ast.type_param]]: name = node.name docstring = get_docstring(node) - arguments = get_arguments(node) + arguments = get_parameters(node) decorators = node.decorator_list type_params = node.type_params return name, docstring, arguments, decorators, type_params -def iter_definitions(module: ast.Module) -> Iterator[Class | Function]: +def iter_definitions(node: ast.Module | ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" - for node in iter_definition_nodes(module): - args = _get_def_args(node) - if isinstance(node, ClassDef): - attrs = get_attributes(node) - yield Class(*args, node.bases, attrs) + for def_ in iter_definition_nodes(node): + args = _get_def_args(def_) + if isinstance(def_, ClassDef): + attrs = get_attributes(def_) + _, functions = get_definitions(def_) # ignore internal classes. + yield Class(def_, *args, def_.bases, attrs, functions) else: - yield Function(*args, node.returns, type(node)) + yield Function(def_, *args, def_.returns) + + +def get_definitions(node: ast.Module | ClassDef) -> tuple[Classes, Functions]: + """Return a tuple of ([Classes], [Functions]) instances.""" + classes: list[Class] = [] + functions: list[Function] = [] + for obj in iter_definitions(node): + if isinstance(obj, Class): + classes.append(obj) + else: + functions.append(obj) + return Classes(classes), Functions(functions) @dataclass -class Module: +class Module(Node): """Module class.""" - docstring: str | None - imports: dict[str, str] + imports: Imports attributes: Attributes classes: Classes functions: Functions @@ -321,16 +375,10 @@ class Module: def get_module(node: ast.Module) -> Module: """Return a [Module] instance.""" docstring = get_docstring(node) - imports = dict(iter_import_names(node)) + imports = get_imports(node) attrs = get_attributes(node) - classes: list[Class] = [] - functions: list[Function] = [] - for obj in iter_definitions(node): - if isinstance(obj, Class): - classes.append(obj) - else: - functions.append(obj) - return Module(docstring, imports, attrs, Classes(classes), Functions(functions)) + classes, functions = get_definitions(node) + return Module(node, "", docstring, imports, attrs, classes, functions) class Transformer(ast.NodeTransformer): # noqa: D101 @@ -352,7 +400,7 @@ def visit_Constant(self, node: ast.Constant) -> ast.Constant | ast.Name: # noqa def iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: - """Yield identifiers as a tuple of (code, is_identifier).""" + """Yield identifiers as a tuple of (code, isidentifier).""" start = 0 while start < len(source): index = source.find("__mkapi__.", start) diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index b91eedcf..4d0f3bc3 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -1,680 +1,93 @@ -"""Docstring module.""" +"""Parse docstring.""" from __future__ import annotations +import inspect import re -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal, Self +from re import Pattern -from mkapi.utils import ( - add_admonition, - add_fence, - delete_ptags, - get_indent, - join_without_indent, -) +# import re +# from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal -if TYPE_CHECKING: - from collections.abc import Callable, Iterator - -LINK_PATTERN = re.compile(r"\[(.+?)\]\[(.+?)\]") - -SECTION_NAMES = [ - "Args", - "Arguments", - "Attributes", - "Example", - "Examples", - "Note", - "Notes", - "Parameters", - "Raises", - "Returns", - "References", - "See Also", - "Todo", - "Warning", - "Warnings", - "Warns", - "Yield", - "Yields", -] -type Style = Literal["google", "numpy", ""] - - -@dataclass -class Base: - """Base class. - - Examples: - >>> base = Base("x", "markdown") - >>> base - Base('x') - >>> bool(base) - True - >>> list(base) - [Base('x')] - >>> base = Base() - >>> bool(base) - False - >>> list(base) - [] - """ - - name: str = "" - markdown: str = "" - html: str = field(default="", init=False) - callback: Callable[[Self], str] | None = field(default=None, init=False) - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name!r})" - - def __bool__(self) -> bool: - """Return True if name is not empty.""" - return bool(self.name) - - def __iter__(self) -> Iterator[Self]: - """Yield self if markdown is not empty.""" - if self.markdown: - yield self - - def set_html(self, html: str) -> None: - """Set HTML.""" - self.html = html - if self.callback: - self.html = self.callback(self) - - def copy(self) -> Self: - """Return a copy of the instance.""" - return self.__class__(name=self.name, markdown=self.markdown) - - -@dataclass(repr=False) -class Inline(Base): - """Inline class. - - Examples: - >>> inline = Inline() - >>> bool(inline) - False - >>> inline = Inline("markdown") - >>> inline.name == inline.markdown - True - >>> inline - Inline('markdown') - >>> bool(inline) - True - >>> next(iter(inline)) is inline - True - >>> inline.set_html("

p1

p2

") - >>> inline.html - 'p1
p2' - >>> inline.copy() - Inline('markdown') - """ - - markdown: str = field(init=False) - - def __post_init__(self) -> None: - self.markdown = self.name - - def set_html(self, html: str) -> None: - """Set `html` attribute cleaning `p` tags.""" - html = delete_ptags(html) - super().set_html(html) - - def copy(self) -> Self: # noqa: D102 - return self.__class__(name=self.name) - - -@dataclass(repr=False) -class Type(Inline): - """Type of [Item], [Section], [Docstring], or [Object]. - - Examples: - >>> a = Type("str") - >>> a - Type('str') - >>> list(a) - [] - >>> b = Type("[Base][docstring.Base]") - >>> b.markdown - '[Base][docstring.Base]' - >>> list(b) - [Type('[Base][docstring.Base]')] - >>> a.copy() - Type('str') - """ +# from mkapi.utils import ( +# add_admonition, +# add_fence, +# delete_ptags, +# get_indent, +# join_without_indent, +# ) - markdown: str = field(default="", init=False) - - def __post_init__(self) -> None: - if LINK_PATTERN.search(self.name): - self.markdown = self.name - else: - self.html = self.name - - -@dataclass -class Item(Type): - """Item in Parameters, Attributes, and Raises sections, *etc.*. - - Args: - type: Type of self. - description: Description of self. - kind: Kind of self, for example `readonly property`. This value is rendered - as a class attribute in HTML. - - Examples: - >>> item = Item('[x][x]', Type('int'), Inline('A parameter.')) - >>> item - Item('[x][x]', 'int') - >>> item.name, item.markdown, item.html - ('[x][x]', '[x][x]', '') - >>> item.type - Type('int') - >>> item.description - Inline('A parameter.') - >>> item = Item('[x][x]', 'str', 'A parameter.') - >>> item.type - Type('str') - >>> it = iter(item) - >>> next(it) is item - True - >>> next(it) is item.description - True - >>> item.set_html('

init

') - >>> item.html - '__init__' - """ +if TYPE_CHECKING: + from collections.abc import Iterator - markdown: str = field(default="", init=False) - type: Type = field(default_factory=Type) # noqa: A003 - description: Inline = field(default_factory=Inline) - kind: str = "" - - def __post_init__(self) -> None: - if isinstance(self.type, str): - self.type = Type(self.type) - if isinstance(self.description, str): - self.description = Inline(self.description) - super().__post_init__() - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name!r}, {self.type.name!r})" - - def __iter__(self) -> Iterator[Base]: - if self.markdown: - yield self - yield from self.type - yield from self.description - - def set_html(self, html: str) -> None: - """Set `html` attribute cleaning `strong` tags.""" - html = html.replace("", "__").replace("", "__") - super().set_html(html) - - def to_tuple(self) -> tuple[str, str, str]: - """Return a tuple of (name, type, description). - - Examples: - >>> item = Item("[x][x]", "int", "A parameter.") - >>> item.to_tuple() - ('[x][x]', 'int', 'A parameter.') - """ - return self.name, self.type.name, self.description.name - - def set_type(self, type_: Type, *, force: bool = False) -> None: - """Set type. - - Args: - type_: Type instance. - force: If True, overwrite self regardless of existing type and - description. - - See Also: - * [Item.update] - """ - if not force and self.type.name: - return - if type_.name: - self.type = type_.copy() - - def set_description(self, description: Inline, *, force: bool = False) -> None: - """Set description. - - Args: - description: Inline instance. - force: If True, overwrite self regardless of existing type and - description. - - See Also: - * [Item.update] - """ - if not force and self.description.name: - return - if description.name: - self.description = description.copy() - - def update(self, item: Item, *, force: bool = False) -> None: - """Update type and description. - - Args: - item: Item instance. - force: If True, overwrite self regardless of existing type and - description. - - Examples: - >>> item = Item("x") - >>> item2 = Item("x", "int", "description") - >>> item.update(item2) - >>> item.to_tuple() - ('x', 'int', 'description') - >>> item2 = Item("x", "str", "new description") - >>> item.update(item2) - >>> item.to_tuple() - ('x', 'int', 'description') - >>> item.update(item2, force=True) - >>> item.to_tuple() - ('x', 'str', 'new description') - >>> item.update(Item("x"), force=True) - >>> item.to_tuple() - ('x', 'str', 'new description') - """ - if item.name != self.name: - msg = f"Different name: {self.name} != {item.name}." - raise ValueError(msg) - self.set_description(item.description, force=force) - self.set_type(item.type, force=force) - - def copy(self) -> Self: # noqa: D102 - name, type_, desc = self.to_tuple() - return self.__class__(name, Type(type_), Inline(desc), kind=self.kind) - - -@dataclass -class Section(Base): - """Section in docstring. +type Style = Literal["google", "numpy"] - Args: - items: List for Arguments, Attributes, or Raises sections, *etc.* - type: Type of self. - - Examples: - >>> items = [Item("x"), Item("[y][a]"), Item("z")] - >>> section = Section("Parameters", items=items) - >>> section - Section('Parameters', num_items=3) - >>> list(section) - [Item('[y][a]', '')] - """ +SPLIT_SECTION_PATTERNS: dict[Style, Pattern[str]] = { + "google": re.compile(r"\n\n\S"), + "numpy": re.compile(r"\n\n\n|\n\n.+?\n-+\n"), +} - items: list[Item] = field(default_factory=list) - type: Type = field(default_factory=Type) # noqa: A003 - - def __post_init__(self) -> None: - if self.markdown: - self.markdown = add_fence(self.markdown) - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name!r}, num_items={len(self.items)})" - - def __bool__(self) -> bool: - """Return True if the number of items is larger than 0.""" - return len(self.items) > 0 - - def __iter__(self) -> Iterator[Base]: - """Yield a Base_ instance that has non empty Markdown.""" - yield from self.type - if self.markdown: - yield self - for item in self.items: - yield from item - - def __getitem__(self, name: str) -> Item: - """Return an Item_ instance whose name is equal to `name`. - - If there is no Item instance, a Item instance is newly created. - - Args: - name: Item name. - - Examples: - >>> section = Section("", items=[Item("x")]) - >>> section["x"] - Item('x', '') - >>> section["y"] - Item('y', '') - >>> section.items - [Item('x', ''), Item('y', '')] - """ - for item in self.items: - if item.name == name: - return item - item = Item(name) - self.items.append(item) - return item - - def __delitem__(self, name: str) -> None: - """Delete an Item_ instance whose name is equal to `name`. - - Args: - name: Item name. - """ - for k, item in enumerate(self.items): - if item.name == name: - del self.items[k] - return - msg = f"name not found: {name}" - raise KeyError(msg) - - def __contains__(self, name: str) -> bool: - """Return True if there is an [Item] instance whose name is equal to `name`. - - Args: - name: Item name. - """ - return any(item.name == name for item in self.items) - - def set_item(self, item: Item, *, force: bool = False) -> None: - """Set an [Item]. - - Args: - item: Item instance. - force: If True, overwrite self regardless of existing item. - - Examples: - >>> items = [Item("x", "int"), Item("y", "str", "y")] - >>> section = Section("Parameters", items=items) - >>> section.set_item(Item("x", "float", "X")) - >>> section["x"].to_tuple() - ('x', 'int', 'X') - >>> section.set_item(Item("y", "int", "Y"), force=True) - >>> section["y"].to_tuple() - ('y', 'int', 'Y') - >>> section.set_item(Item("z", "float", "Z")) - >>> [item.name for item in section.items] - ['x', 'y', 'z'] - - See Also: - * Section.update_ - """ - for k, x in enumerate(self.items): - if x.name == item.name: - self.items[k].update(item, force=force) - return - self.items.append(item.copy()) - - def update(self, section: Section, *, force: bool = False) -> None: - """Update items. - - Args: - section: Section instance. - force: If True, overwrite items of self regardless of existing value. - - Examples: - >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) - >>> s2 = Section("Parameters", items=[Item("a", "i", "A"), Item("x", "d")]) - >>> s1.update(s2) - >>> s1["a"].to_tuple() - ('a', 's', 'A') - >>> s1["x"].to_tuple() - ('x', 'd', '') - >>> s1.update(s2, force=True) - >>> s1["a"].to_tuple() - ('a', 'i', 'A') - >>> s1.items - [Item('a', 'i'), Item('b', 'f'), Item('x', 'd')] - """ - for item in section.items: - self.set_item(item, force=force) - - def merge(self, section: Section, *, force: bool = False) -> Section: - """Return a merged Section. - - Examples: - >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) - >>> s2 = Section("Parameters", items=[Item("a", "i"), Item("c", "d")]) - >>> s3 = s1.merge(s2) - >>> s3.items - [Item('a', 's'), Item('b', 'f'), Item('c', 'd')] - >>> s3 = s1.merge(s2, force=True) - >>> s3.items - [Item('a', 'i'), Item('b', 'f'), Item('c', 'd')] - >>> s3 = s2.merge(s1) - >>> s3.items - [Item('a', 'i'), Item('c', 'd'), Item('b', 'f')] - """ - if section.name != self.name: - msg = f"Different name: {self.name} != {section.name}." - raise ValueError(msg) - merged = Section(self.name) - for item in self.items: - merged.set_item(item) - for item in section.items: - merged.set_item(item, force=force) - return merged - - def copy(self) -> Self: - """Return a copy of the instace. - - Examples: - >>> s = Section("E", "markdown", [Item("a", "s"), Item("b", "i")]) - >>> s.copy() - Section('E', num_items=2) - """ - items = [item.copy() for item in self.items] - return self.__class__(self.name, self.markdown, items=items) - - -SECTION_ORDER = ["Bases", "", "Parameters", "Attributes", "Returns", "Yields", "Raises"] - - -@dataclass -class Docstring: - """Docstring of an object. - Args: - sections: List of [Section] instance. - type: [Type] for Returns or Yields sections. - - Examples: - Empty docstring: - >>> docstring = Docstring() - >>> assert not docstring - - Docstring with 3 sections: - >>> default = Section("", markdown="Default") - >>> parameters = Section("Parameters", items=[Item("a"), Item("[b][!a]")]) - >>> returns = Section("Returns", markdown="Results") - >>> docstring = Docstring([default, parameters, returns]) - >>> docstring - Docstring(num_sections=3) - - `Docstring` is iterable: - >>> list(docstring) - [Section('', num_items=0), Item('[b][!a]', ''), Section('Returns', num_items=0)] - - Indexing: - >>> docstring["Parameters"].items[0].name - 'a' - - Section ordering: - >>> docstring = Docstring() - >>> _ = docstring[""] - >>> _ = docstring["Todo"] - >>> _ = docstring["Attributes"] - >>> _ = docstring["Parameters"] - >>> [section.name for section in docstring.sections] - ['', 'Parameters', 'Attributes', 'Todo'] - """ +def _iter_sections(doc: str, style: Style) -> Iterator[str]: + pattern = SPLIT_SECTION_PATTERNS[style] + if not (m := re.search("\n\n", doc)): + yield doc.strip() + return + start = m.end() + yield doc[:start].strip() + for m in pattern.finditer(doc, start): + yield from _subsplit(doc[start : m.start()].strip(), style) + start = m.start() + yield from _subsplit(doc[start:].strip(), style) + + +# In Numpy style, if a section is indented, then a section break is +# created by resuming unindented text. +def _subsplit(doc: str, style: Style) -> list[str]: + if style == "google" or len(lines := doc.split("\n")) < 3: # noqa: PLR2004 + return [doc] + if not lines[2].startswith(" "): # 2 == after '----' line. + return [doc] + return doc.split("\n\n") + + +SECTION_NAMES: list[tuple[str, ...]] = [ + ("Parameters", "Arguments", "Args"), + ("Attributes",), + ("Examples", "Example"), + ("Returns", "Return"), + ("Raises", "Raise"), + ("Yields", "Yield"), + ("Warnings", "Warning", "Warns", "Warn"), + ("Notes", "Note"), +] - sections: list[Section] = field(default_factory=list) - type: Type = field(default_factory=Type) # noqa: A003 - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - num_sections = len(self.sections) - return f"{class_name}(num_sections={num_sections})" - - def __bool__(self) -> bool: - """Return True if the number of sections is larger than 0.""" - return len(self.sections) > 0 - - def __iter__(self) -> Iterator[Base]: - """Yield [Base]() instance.""" - for section in self.sections: - yield from section - - def __getitem__(self, name: str) -> Section: - """Return a [Section]() instance whose name is equal to `name`. - - If there is no Section instance, a Section instance is newly created. - - Args: - name: Section name. - """ - for section in self.sections: - if section.name == name: - return section - section = Section(name) - self.set_section(section) - return section - - def __contains__(self, name: str) -> bool: - """Return True if there is a [Section]() instance whose name is equal to `name`. - - Args: - name: Section name. - """ - return any(section.name == name for section in self.sections) - - def set_section( - self, - section: Section, - *, - force: bool = False, - copy: bool = False, - replace: bool = False, - ) -> None: - """Set a [Section]. - - Args: - section: Section instance. - force: If True, overwrite self regardless of existing seciton. - copy: If True, section is copied. - replace: If True,section is replaced. - - Examples: - >>> items = [Item("x", "int"), Item("y", "str", "y")] - >>> s1 = Section('Attributes', items=items) - >>> items = [Item("x", "str", "X"), Item("z", "str", "z")] - >>> s2 = Section("Attributes", items=items) - >>> doc = Docstring([s1]) - >>> doc.set_section(s2) - >>> doc["Attributes"]["x"].to_tuple() - ('x', 'int', 'X') - >>> doc["Attributes"]["z"].to_tuple() - ('z', 'str', 'z') - >>> doc.set_section(s2, force=True) - >>> doc["Attributes"]["x"].to_tuple() - ('x', 'str', 'X') - >>> items = [Item("x", "X", "str"), Item("z", "z", "str")] - >>> s3 = Section("Parameters", items=items) - >>> doc.set_section(s3) - >>> doc.sections - [Section('Parameters', num_items=2), Section('Attributes', num_items=3)] - """ - name = section.name - for k, x in enumerate(self.sections): - if x.name == name: - if replace: - self.sections[k] = section - else: - self.sections[k].update(section, force=force) - return - if copy: - section = section.copy() - if name not in SECTION_ORDER: - self.sections.append(section) - return - order = SECTION_ORDER.index(name) - for k, x in enumerate(self.sections): - if x.name not in SECTION_ORDER: - self.sections.insert(k, section) - return - order_ = SECTION_ORDER.index(x.name) - if order < order_: - self.sections.insert(k, section) - return - self.sections.append(section) - - -def _rename_section(name: str) -> str: - if name in ["Args", "Arguments"]: - return "Parameters" - if name == "Warns": - return "Warnings" - return name - - -def section_heading(line: str) -> tuple[str, Style]: - """Return a tuple of (section name, style name). - Args: - line: Docstring line. - - Examples: - >>> section_heading("Args:") - ('Args', 'google') - >>> section_heading("Raises") - ('Raises', 'numpy') - >>> section_heading("other") - ('', '') - """ - if line in SECTION_NAMES: - return line, "numpy" - if line.endswith(":") and line[:-1] in SECTION_NAMES: - return line[:-1], "google" - return "", "" +def _rename_section(section_name: str) -> str: + for section_names in SECTION_NAMES: + if section_name in section_names: + return section_names[0] + return section_name -def split_section(doc: str) -> Iterator[tuple[str, str, str]]: - r"""Yield a tuple of (section name, contents, style). +def split_section(section: str, style: Style) -> tuple[str, str]: + """Return section name and its description.""" + lines = section.split("\n") + if len(lines) < 2: # noqa: PLR2004 + return "", section + if style == "google" and (m := re.match(r"^([A-Za-z0-9].*):$", lines[0])): + return m.group(1), inspect.cleandoc("\n".join(lines[1:])) + if style == "numpy" and (m := re.match(r"^-+?$", lines[1])): + return lines[0], inspect.cleandoc("\n".join(lines[2:])) + return "", section - Args: - doc: Docstring - Examples: - >>> doc = "abc\n\nArgs:\n x: X\n" - >>> it = split_section(doc) - >>> next(it) - ('', 'abc', '') - >>> next(it) - ('Parameters', 'x: X', 'google') - """ - name = style = "" - start = indent = 0 - lines = [x.rstrip() for x in doc.split("\n")] - for stop, line in enumerate(lines, 1): - next_indent = -1 if stop == len(lines) else get_indent(lines[stop]) - if not line and next_indent < indent and name: - if start < stop - 1: - yield name, join_without_indent(lines[start : stop - 1]), style - start, name = stop, "" - else: - section, style_ = section_heading(line) - if section: - if start < stop - 1: - yield name, join_without_indent(lines[start : stop - 1]), style - style, start, name = style_, stop, _rename_section(section) - if style == "numpy": # skip underline without counting the length. - start += 1 - indent = next_indent - if start < len(lines): - yield name, join_without_indent(lines[start:]), style +def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: + """Yield (section name, description) pairs by splitting the whole docstring.""" + for section in _iter_sections(doc, style): + if section: + name, desc = split_section(section, style) + yield _rename_section(name), desc def split_parameter(doc: str) -> Iterator[list[str]]: @@ -692,84 +105,403 @@ def split_parameter(doc: str) -> Iterator[list[str]]: start = stop -PARAMETER_PATTERN = { - "google": re.compile(r"(.*?)\s*?\((.*?)\)"), - "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), -} +# PARAMETER_PATTERN = { +# "google": re.compile(r"(.*?)\s*?\((.*?)\)"), +# "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), +# } -def parse_parameter(lines: list[str], style: str) -> Item: - """Return a [Item] instance corresponding to a parameter. +# def parse_parameter(lines: list[str], style: str) -> Item: +# """Return a [Item] instance corresponding to a parameter. - Args: - lines: Splitted parameter docstring lines. - style: Docstring style. `google` or `numpy`. - """ - if style == "google": - name, _, line = lines[0].partition(":") - name, parsed = name.strip(), [line.strip()] - else: - name, parsed = lines[0].strip(), [] - if len(lines) > 1: - indent = get_indent(lines[1]) - for line in lines[1:]: - parsed.append(line[indent:]) - if m := re.match(PARAMETER_PATTERN[style], name): - name, type_ = m.group(1), m.group(2) - else: - type_ = "" - return Item(name, Type(type_), Inline("\n".join(parsed))) - - -def parse_parameters(doc: str, style: str) -> list[Item]: - """Return a list of Item.""" - return [parse_parameter(lines, style) for lines in split_parameter(doc)] - - -def parse_returns(doc: str, style: str) -> tuple[str, str]: - """Return a tuple of (type, markdown).""" - type_, lines = "", doc.split("\n") - if style == "google": - if ":" in lines[0]: - type_, _, lines[0] = lines[0].partition(":") - type_ = type_.strip() - lines[0] = lines[0].strip() - else: - type_, lines = lines[0].strip(), lines[1:] - return type_, join_without_indent(lines) - - -def parse_section(name: str, doc: str, style: str) -> Section: - """Return a [Section] instance.""" - type_ = markdown = "" - items = [] - if name in ["Parameters", "Attributes", "Raises"]: - items = parse_parameters(doc, style) - elif name in ["Returns", "Yields"]: - type_, markdown = parse_returns(doc, style) - else: - markdown = doc - return Section(name, markdown, items, Type(type_)) - - -def postprocess_sections(sections: list[Section]) -> None: # noqa: D103 - for section in sections: - if section.name in ["Note", "Notes", "Warning", "Warnings"]: - markdown = add_admonition(section.name, section.markdown) - if sections and sections[-1].name == "": - sections[-1].markdown += "\n\n" + markdown - continue - section.name = "" - section.markdown = markdown - - -def parse_docstring(doc: str) -> Docstring: - """Return a [Docstring]) instance.""" - if not doc: - return Docstring() - sections = [parse_section(*section_args) for section_args in split_section(doc)] - postprocess_sections(sections) - return Docstring(sections) +# Args: +# lines: Splitted parameter docstring lines. +# style: Docstring style. `google` or `numpy`. +# """ +# if style == "google": +# name, _, line = lines[0].partition(":") +# name, parsed = name.strip(), [line.strip()] +# else: +# name, parsed = lines[0].strip(), [] +# if len(lines) > 1: +# indent = get_indent(lines[1]) +# for line in lines[1:]: +# parsed.append(line[indent:]) +# if m := re.match(PARAMETER_PATTERN[style], name): +# name, type_ = m.group(1), m.group(2) +# else: +# type_ = "" +# return Item(name, Type(type_), Inline("\n".join(parsed))) + + +# def parse_parameters(doc: str, style: str) -> list[Item]: +# """Return a list of Item.""" +# return [parse_parameter(lines, style) for lines in split_parameter(doc)] + + +# def parse_returns(doc: str, style: str) -> tuple[str, str]: +# """Return a tuple of (type, markdown).""" +# type_, lines = "", doc.split("\n") +# if style == "google": +# if ":" in lines[0]: +# type_, _, lines[0] = lines[0].partition(":") +# type_ = type_.strip() +# lines[0] = lines[0].strip() +# else: +# type_, lines = lines[0].strip(), lines[1:] +# return type_, join_without_indent(lines) + + +# def parse_section(name: str, doc: str, style: str) -> Section: +# """Return a [Section] instance.""" +# type_ = markdown = "" +# items = [] +# if name in ["Parameters", "Attributes", "Raises"]: +# items = parse_parameters(doc, style) +# elif name in ["Returns", "Yields"]: +# type_, markdown = parse_returns(doc, style) +# else: +# markdown = doc +# return Section(name, markdown, items, Type(type_)) + + +# def postprocess_sections(sections: list[Section]) -> None: +# for section in sections: +# if section.name in ["Note", "Notes", "Warning", "Warnings"]: +# markdown = add_admonition(section.name, section.markdown) +# if sections and sections[-1].name == "": +# sections[-1].markdown += "\n\n" + markdown +# continue +# section.name = "" +# section.markdown = markdown + + +# def parse_docstring(doc: str) -> Docstring: +# """Return a [Docstring]) instance.""" +# if not doc: +# return Docstring() +# sections = [parse_section(*section_args) for section_args in split_section(doc)] +# postprocess_sections(sections) +# return Docstring(sections) + + +# @dataclass +# class Section(Base): +# """Section in docstring. + +# Args: +# items: List for Arguments, Attributes, or Raises sections, *etc.* +# type: Type of self. + +# Examples: +# >>> items = [Item("x"), Item("[y][a]"), Item("z")] +# >>> section = Section("Parameters", items=items) +# >>> section +# Section('Parameters', num_items=3) +# >>> list(section) +# [Item('[y][a]', '')] +# """ + +# items: list[Item] = field(default_factory=list) +# type: Type = field(default_factory=Type) # noqa: A003 + +# def __post_init__(self) -> None: +# if self.markdown: +# self.markdown = add_fence(self.markdown) + +# def __repr__(self) -> str: +# class_name = self.__class__.__name__ +# return f"{class_name}({self.name!r}, num_items={len(self.items)})" + +# def __bool__(self) -> bool: +# """Return True if the number of items is larger than 0.""" +# return len(self.items) > 0 + +# def __iter__(self) -> Iterator[Base]: +# """Yield a Base_ instance that has non empty Markdown.""" +# yield from self.type +# if self.markdown: +# yield self +# for item in self.items: +# yield from item + +# def __getitem__(self, name: str) -> Item: +# """Return an Item_ instance whose name is equal to `name`. + +# If there is no Item instance, a Item instance is newly created. + +# Args: +# name: Item name. + +# Examples: +# >>> section = Section("", items=[Item("x")]) +# >>> section["x"] +# Item('x', '') +# >>> section["y"] +# Item('y', '') +# >>> section.items +# [Item('x', ''), Item('y', '')] +# """ +# for item in self.items: +# if item.name == name: +# return item +# item = Item(name) +# self.items.append(item) +# return item + +# def __delitem__(self, name: str) -> None: +# """Delete an Item_ instance whose name is equal to `name`. + +# Args: +# name: Item name. +# """ +# for k, item in enumerate(self.items): +# if item.name == name: +# del self.items[k] +# return +# msg = f"name not found: {name}" +# raise KeyError(msg) + +# def __contains__(self, name: str) -> bool: +# """Return True if there is an [Item] instance whose name is equal to `name`. + +# Args: +# name: Item name. +# """ +# return any(item.name == name for item in self.items) + +# def set_item(self, item: Item, *, force: bool = False) -> None: +# """Set an [Item]. + +# Args: +# item: Item instance. +# force: If True, overwrite self regardless of existing item. + +# Examples: +# >>> items = [Item("x", "int"), Item("y", "str", "y")] +# >>> section = Section("Parameters", items=items) +# >>> section.set_item(Item("x", "float", "X")) +# >>> section["x"].to_tuple() +# ('x', 'int', 'X') +# >>> section.set_item(Item("y", "int", "Y"), force=True) +# >>> section["y"].to_tuple() +# ('y', 'int', 'Y') +# >>> section.set_item(Item("z", "float", "Z")) +# >>> [item.name for item in section.items] +# ['x', 'y', 'z'] + +# See Also: +# * Section.update_ +# """ +# for k, x in enumerate(self.items): +# if x.name == item.name: +# self.items[k].update(item, force=force) +# return +# self.items.append(item.copy()) + +# def update(self, section: Section, *, force: bool = False) -> None: +# """Update items. + +# Args: +# section: Section instance. +# force: If True, overwrite items of self regardless of existing value. + +# Examples: +# >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) +# >>> s2 = Section("Parameters", items=[Item("a", "i", "A"), Item("x", "d")]) +# >>> s1.update(s2) +# >>> s1["a"].to_tuple() +# ('a', 's', 'A') +# >>> s1["x"].to_tuple() +# ('x', 'd', '') +# >>> s1.update(s2, force=True) +# >>> s1["a"].to_tuple() +# ('a', 'i', 'A') +# >>> s1.items +# [Item('a', 'i'), Item('b', 'f'), Item('x', 'd')] +# """ +# for item in section.items: +# self.set_item(item, force=force) + +# def merge(self, section: Section, *, force: bool = False) -> Section: +# """Return a merged Section. + +# Examples: +# >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) +# >>> s2 = Section("Parameters", items=[Item("a", "i"), Item("c", "d")]) +# >>> s3 = s1.merge(s2) +# >>> s3.items +# [Item('a', 's'), Item('b', 'f'), Item('c', 'd')] +# >>> s3 = s1.merge(s2, force=True) +# >>> s3.items +# [Item('a', 'i'), Item('b', 'f'), Item('c', 'd')] +# >>> s3 = s2.merge(s1) +# >>> s3.items +# [Item('a', 'i'), Item('c', 'd'), Item('b', 'f')] +# """ +# if section.name != self.name: +# msg = f"Different name: {self.name} != {section.name}." +# raise ValueError(msg) +# merged = Section(self.name) +# for item in self.items: +# merged.set_item(item) +# for item in section.items: +# merged.set_item(item, force=force) +# return merged + +# def copy(self) -> Self: +# """Return a copy of the instace. + +# Examples: +# >>> s = Section("E", "markdown", [Item("a", "s"), Item("b", "i")]) +# >>> s.copy() +# Section('E', num_items=2) +# """ +# items = [item.copy() for item in self.items] +# return self.__class__(self.name, self.markdown, items=items) + + +# SECTION_ORDER = ["Bases", "", "Parameters", "Attributes", "Returns", "Yields", "Raises"] + + +# @dataclass +# class Docstring: +# """Docstring of an object. + +# Args: +# sections: List of [Section] instance. +# type: [Type] for Returns or Yields sections. + +# Examples: +# Empty docstring: +# >>> docstring = Docstring() +# >>> assert not docstring + +# Docstring with 3 sections: +# >>> default = Section("", markdown="Default") +# >>> parameters = Section("Parameters", items=[Item("a"), Item("[b][!a]")]) +# >>> returns = Section("Returns", markdown="Results") +# >>> docstring = Docstring([default, parameters, returns]) +# >>> docstring +# Docstring(num_sections=3) + +# `Docstring` is iterable: +# >>> list(docstring) +# [Section('', num_items=0), Item('[b][!a]', ''), Section('Returns', num_items=0)] + +# Indexing: +# >>> docstring["Parameters"].items[0].name +# 'a' + +# Section ordering: +# >>> docstring = Docstring() +# >>> _ = docstring[""] +# >>> _ = docstring["Todo"] +# >>> _ = docstring["Attributes"] +# >>> _ = docstring["Parameters"] +# >>> [section.name for section in docstring.sections] +# ['', 'Parameters', 'Attributes', 'Todo'] +# """ + +# sections: list[Section] = field(default_factory=list) +# type: Type = field(default_factory=Type) # noqa: A003 + +# def __repr__(self) -> str: +# class_name = self.__class__.__name__ +# num_sections = len(self.sections) +# return f"{class_name}(num_sections={num_sections})" + +# def __bool__(self) -> bool: +# """Return True if the number of sections is larger than 0.""" +# return len(self.sections) > 0 + +# def __iter__(self) -> Iterator[Base]: +# """Yield [Base]() instance.""" +# for section in self.sections: +# yield from section + +# def __getitem__(self, name: str) -> Section: +# """Return a [Section]() instance whose name is equal to `name`. + +# If there is no Section instance, a Section instance is newly created. + +# Args: +# name: Section name. +# """ +# for section in self.sections: +# if section.name == name: +# return section +# section = Section(name) +# self.set_section(section) +# return section + +# def __contains__(self, name: str) -> bool: +# """Return True if there is a [Section]() instance whose name is equal to `name`. + +# Args: +# name: Section name. +# """ +# return any(section.name == name for section in self.sections) + +# def set_section( +# self, +# section: Section, +# *, +# force: bool = False, +# copy: bool = False, +# replace: bool = False, +# ) -> None: +# """Set a [Section]. + +# Args: +# section: Section instance. +# force: If True, overwrite self regardless of existing seciton. +# copy: If True, section is copied. +# replace: If True,section is replaced. + +# Examples: +# >>> items = [Item("x", "int"), Item("y", "str", "y")] +# >>> s1 = Section('Attributes', items=items) +# >>> items = [Item("x", "str", "X"), Item("z", "str", "z")] +# >>> s2 = Section("Attributes", items=items) +# >>> doc = Docstring([s1]) +# >>> doc.set_section(s2) +# >>> doc["Attributes"]["x"].to_tuple() +# ('x', 'int', 'X') +# >>> doc["Attributes"]["z"].to_tuple() +# ('z', 'str', 'z') +# >>> doc.set_section(s2, force=True) +# >>> doc["Attributes"]["x"].to_tuple() +# ('x', 'str', 'X') +# >>> items = [Item("x", "X", "str"), Item("z", "z", "str")] +# >>> s3 = Section("Parameters", items=items) +# >>> doc.set_section(s3) +# >>> doc.sections +# [Section('Parameters', num_items=2), Section('Attributes', num_items=3)] +# """ +# name = section.name +# for k, x in enumerate(self.sections): +# if x.name == name: +# if replace: +# self.sections[k] = section +# else: +# self.sections[k].update(section, force=force) +# return +# if copy: +# section = section.copy() +# if name not in SECTION_ORDER: +# self.sections.append(section) +# return +# order = SECTION_ORDER.index(name) +# for k, x in enumerate(self.sections): +# if x.name not in SECTION_ORDER: +# self.sections.insert(k, section) +# return +# order_ = SECTION_ORDER.index(x.name) +# if order < order_: +# self.sections.insert(k, section) +# return +# self.sections.append(section) # def parse_bases(doc: Docstring, obj: object) -> None: diff --git a/tests/ast/test_args.py b/tests/ast/test_args.py index e1d78111..16b64108 100644 --- a/tests/ast/test_args.py +++ b/tests/ast/test_args.py @@ -1,52 +1,52 @@ import ast from inspect import Parameter -from mkapi.ast import get_arguments +from mkapi.ast import get_parameters def _get_args(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.FunctionDef) - return get_arguments(node) + return get_parameters(node) -def test_get_arguments_1(): +def test_get_parameters_1(): args = _get_args("def f():\n pass") assert not args.items args = _get_args("def f(x):\n pass") - assert args.x.annotation is None + assert args.x.type is None assert args.x.default is None assert args.x.kind is Parameter.POSITIONAL_OR_KEYWORD x = _get_args("def f(x=1):\n pass").x assert isinstance(x.default, ast.Constant) x = _get_args("def f(x:str='s'):\n pass").x - assert isinstance(x.annotation, ast.Name) - assert x.annotation.id == "str" + assert isinstance(x.type, ast.Name) + assert x.type.id == "str" assert isinstance(x.default, ast.Constant) assert x.default.value == "s" x = _get_args("def f(x:'X'='s'):\n pass").x - assert isinstance(x.annotation, ast.Constant) - assert x.annotation.value == "X" + assert isinstance(x.type, ast.Constant) + assert x.type.value == "X" -def test_get_arguments_2(): +def test_get_parameters_2(): x = _get_args("def f(x:tuple[int]=(1,)):\n pass").x - assert isinstance(x.annotation, ast.Subscript) - assert isinstance(x.annotation.value, ast.Name) - assert x.annotation.value.id == "tuple" - assert isinstance(x.annotation.slice, ast.Name) - assert x.annotation.slice.id == "int" + assert isinstance(x.type, ast.Subscript) + assert isinstance(x.type.value, ast.Name) + assert x.type.value.id == "tuple" + assert isinstance(x.type.slice, ast.Name) + assert x.type.slice.id == "int" assert isinstance(x.default, ast.Tuple) assert isinstance(x.default.elts[0], ast.Constant) assert x.default.elts[0].value == 1 -def test_get_arguments_3(): +def test_get_parameters_3(): x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass").x - assert isinstance(x.annotation, ast.Subscript) - assert isinstance(x.annotation.value, ast.Name) - assert x.annotation.value.id == "tuple" - assert isinstance(x.annotation.slice, ast.Tuple) - assert x.annotation.slice.elts[0].id == "int" # type: ignore - assert x.annotation.slice.elts[1].id == "str" # type: ignore + assert isinstance(x.type, ast.Subscript) + assert isinstance(x.type.value, ast.Name) + assert x.type.value.id == "tuple" + assert isinstance(x.type.slice, ast.Tuple) + assert x.type.slice.elts[0].id == "int" # type: ignore + assert x.type.slice.elts[1].id == "str" # type: ignore assert isinstance(x.default, ast.Tuple) diff --git a/tests/ast/test_attrs.py b/tests/ast/test_attrs.py index 5a38c1ed..a9efbc94 100644 --- a/tests/ast/test_attrs.py +++ b/tests/ast/test_attrs.py @@ -12,16 +12,16 @@ def _get_attributes(source: str): def test_get_attributes(): src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" x = _get_attributes(src).x - assert x.annotation is None - assert isinstance(x.value, ast.Call) - assert ast.unparse(x.value.func) == "f.g" + assert x.type is None + assert isinstance(x.default, ast.Call) + assert ast.unparse(x.default.func) == "f.g" assert x.docstring == "docstring" src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" assigns = _get_attributes(src) x, y, z = assigns.items assert x.docstring is None - assert x.value is None + assert x.default is None assert y.docstring == "docstring\na" assert z.docstring is None - assert z.value is not None + assert z.default is not None assert list(assigns) == [x, y, z] diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 86e892db..6bf15260 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -66,9 +66,9 @@ def test_iter_identifiers(): def test_functions(module: Module): func = module.functions._get_def_args # noqa: SLF001 - ann = func.arguments[0].annotation - assert isinstance(ann, ast.expr) - text = StringTransformer().unparse(ann) + type_ = func.parameters[0].type + assert isinstance(type_, ast.expr) + text = StringTransformer().unparse(type_) for s in iter_identifiers(text): print(s) print(module.imports) diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 11becb24..000e8cfd 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -7,7 +7,7 @@ get_module, get_module_node, iter_definition_nodes, - iter_import_names, + iter_import_node_names, iter_import_nodes, ) @@ -39,7 +39,8 @@ def test_iter_import_nodes(module: Module): def test_get_import_names(module: Module): - names = dict(iter_import_names(module)) + it = iter_import_node_names(module) + names = {name: fullname for (_, name, fullname) in it} assert "logging" in names assert names["logging"] == "logging" assert "PurePath" in names diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d965ab8e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import sys +from pathlib import Path + +import pytest + +from mkapi.ast import get_module, get_module_node + + +@pytest.fixture(scope="module") +def google(): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + node = get_module_node("examples.styles.example_google") + return get_module(node) + + +@pytest.fixture(scope="module") +def numpy(): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + node = get_module_node("examples.styles.example_numpy") + return get_module(node) diff --git a/tests/docstring/test_examples.py b/tests/docstring/test_examples.py new file mode 100644 index 00000000..27fa55a7 --- /dev/null +++ b/tests/docstring/test_examples.py @@ -0,0 +1,73 @@ +import pytest + +from mkapi.ast import Module +from mkapi.docstring import iter_sections, split_section + + +def test_split_section_heading(): + f = split_section + for style in ["google", "numpy"]: + assert f("A", style) == ("", "A") # type: ignore + assert f("A:\n a\n b", "google") == ("A", "a\nb") + assert f("A\n a\n b", "google") == ("", "A\n a\n b") + assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") + assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") + assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +def test_iter_sections_short(): + sections = list(iter_sections("", "google")) + assert sections == [] + sections = list(iter_sections("x", "google")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n", "google")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n\n", "google")) + assert sections == [("", "x")] + + +@pytest.mark.parametrize("style", ["google", "numpy"]) +def test_iter_sections_google(style, google: Module, numpy: Module): + doc = google.docstring if style == "google" else numpy.docstring + assert isinstance(doc, str) + sections = list(iter_sections(doc, style)) + if style == "google": + assert len(sections) == 7 + assert sections[0][1].startswith("Example Google") + assert sections[0][1].endswith("docstrings.") + assert sections[1][1].startswith("This module") + assert sections[1][1].endswith("indented text.") + assert sections[2][0] == "Examples" + assert sections[2][1].startswith("Examples can be") + assert sections[2][1].endswith("google.py") + assert sections[3][1].startswith("Section breaks") + assert sections[3][1].endswith("section starts.") + assert sections[4][0] == "Attributes" + assert sections[4][1].startswith("module_level_") + assert sections[4][1].endswith("with it.") + assert sections[5][0] == "Todo" + assert sections[5][1].startswith("* For") + assert sections[5][1].endswith("extension") + assert sections[6][1].startswith("..") + assert sections[6][1].endswith(".html") + else: + assert len(sections) == 8 + assert sections[0][1].startswith("Example NumPy") + assert sections[0][1].endswith("docstrings.") + assert sections[1][1].startswith("This module") + assert sections[1][1].endswith("equal length.") + assert sections[2][0] == "Examples" + assert sections[2][1].startswith("Examples can be") + assert sections[2][1].endswith("numpy.py") + assert sections[3][1].startswith("Section breaks") + assert sections[3][1].endswith("be\nindented:") + assert sections[4][0] == "Notes" + assert sections[4][1].startswith("This is an") + assert sections[4][1].endswith("surrounding text.") + assert sections[5][1].startswith("If a section") + assert sections[5][1].endswith("unindented text.") + assert sections[6][0] == "Attributes" + assert sections[6][1].startswith("module_level") + assert sections[6][1].endswith("with it.") + assert sections[7][1].startswith("..") + assert sections[7][1].endswith(".rst.txt") diff --git a/tests/test_docstring.py b/tests/test_docstring.py deleted file mode 100644 index 53f0bd8e..00000000 --- a/tests/test_docstring.py +++ /dev/null @@ -1,183 +0,0 @@ -import pytest - -from mkapi.docstring import ( - Base, - Docstring, - Inline, - Item, - Section, - Type, - _rename_section, - parse_parameter, - parse_returns, - split_parameter, - split_section, -) - - -def test_update_item(): - a = Item("a", Type("int"), Inline("aaa")) - b = Item("b", Type("str"), Inline("bbb")) - with pytest.raises(ValueError): # noqa: PT011 - a.update(b) - - -def test_section_delete_item(): - a = Item("a", Type("int"), Inline("aaa")) - b = Item("b", Type("str"), Inline("bbb")) - c = Item("c", Type("float"), Inline("ccc")) - s = Section("Parameters", items=[a, b, c]) - del s["b"] - assert "b" not in s - with pytest.raises(KeyError): - del s["x"] - - -def test_section_merge(): - a = Section("a") - b = Section("b") - with pytest.raises(ValueError): # noqa: PT011 - a.merge(b) - - -def test_docstring_copy(): - d = Docstring() - a = Section("Parameters") - d.set_section(a) - assert "Parameters" in d - assert d["Parameters"] is a - a = Section("Arguments") - d.set_section(a, copy=True) - assert "Arguments" in d - assert d["Arguments"] is not a - - -def test_copy(): - x = Base("x", "markdown") - y = x.copy() - assert y.name == "x" - assert y.markdown == "markdown" - - -def test_rename_section(): - assert _rename_section("Warns") == "Warnings" - - -doc = {} -doc["google"] = """a - - b - -c - -Args: - x (int): The first - parameter - - with type. - y: The second - parameter - - without type. - -Raises: - ValueError: a - b - TypeError: c - d - -Returns: - int: e - - f -""" - -doc["numpy"] = """a - - b - -c - -Parameters ----------- -x : int - The first - parameter - - with type. -y - The second - parameter - - without type. - -Raises ------- -ValueError - a - b -TypeError - c - d - -Returns -------- -int - e - - f -""" - - -@pytest.mark.parametrize("style", ["google", "numpy"]) -def test_split_section(style): - it = split_section(doc[style]) - section, body, style = next(it) - assert section == "" - assert body == "a\n\n b\n\nc" - section, body, style = next(it) - assert section == "Parameters" - if style == "google": - assert body.startswith("x (int)") - else: - assert body.startswith("x : int") - assert body.endswith("\n without type.") - section, body, style = next(it) - assert section == "Raises" - if style == "google": - assert body.startswith("ValueError: a") - else: - assert body.startswith("ValueError\n a") - assert body.endswith("\n d") - - -@pytest.mark.parametrize("style", ["google", "numpy"]) -def test_parse_parameter(style): - it = split_section(doc[style]) - next(it) - section, body, style = next(it) - it = split_parameter(body) - lines = next(it) - assert len(lines) == 4 if style == "goole" else 5 - item = parse_parameter(lines, style) - assert item.name == "x" - assert item.description.markdown == "The first\nparameter\n\nwith type." - assert item.type.name == "int" - lines = next(it) - assert len(lines) == 4 if style == "goole" else 5 - item = parse_parameter(lines, style) - assert item.name == "y" - assert item.description.markdown == "The second\nparameter\n\nwithout type." - assert item.type.name == "" - - -@pytest.mark.parametrize("style", ["google", "numpy"]) -def test_parse_returns(style): - it = split_section(doc[style]) - next(it) - next(it) - next(it) - section, body, style = next(it) - type_, markdown = parse_returns(body, style) - assert markdown == "e\n\nf" - assert type_ == "int" From b5e2fa9cb88168953088369fcb488be8eed58287 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 3 Jan 2024 20:00:34 +0900 Subject: [PATCH 047/148] docstring.py --- src/mkapi/ast.py | 18 +- src/mkapi/docstring.py | 662 ++++++------------------------- src/mkapi/utils.py | 41 +- tests/docstring/test_examples.py | 73 ---- tests/docstring/test_google.py | 131 ++++++ tests/docstring/test_numpy.py | 128 ++++++ 6 files changed, 410 insertions(+), 643 deletions(-) delete mode 100644 tests/docstring/test_examples.py create mode 100644 tests/docstring/test_google.py create mode 100644 tests/docstring/test_numpy.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 18c64aef..86f16f8a 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -15,7 +15,7 @@ ) from dataclasses import dataclass from importlib.util import find_spec -from inspect import Parameter as P +from inspect import Parameter as P # noqa: N817 from inspect import cleandoc from pathlib import Path from typing import TYPE_CHECKING @@ -128,7 +128,7 @@ def get_docstring(node: Doc) -> str | None: @dataclass class Node: - """Base class.""" + """Node class.""" _node: AST name: str @@ -149,11 +149,11 @@ class Nodes[T]: def __getitem__(self, index: int | str) -> T: if isinstance(index, int): return self.items[index] - return getattr(self, index) + names = [item.name for item in self.items] # type: ignore # noqa: PGH003 + return self.items[names.index(index)] def __getattr__(self, name: str) -> T: - names = [item.name for item in self.items] # type: ignore # noqa: PGH003 - return self.items[names.index(name)] + return self[name] def __iter__(self) -> Iterator[T]: return iter(self.items) @@ -215,15 +215,15 @@ def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: @dataclass(repr=False) -class XXXX(Node): - """XXXX class.""" +class Argument(Node): + """Argument class.""" type: ast.expr | None # # noqa: A003 default: ast.expr | None @dataclass(repr=False) -class Parameter(XXXX): +class Parameter(Argument): """Parameter class.""" _node: ast.arg @@ -251,7 +251,7 @@ def get_parameters(node: FunctionDef_ | ClassDef) -> Parameters: @dataclass(repr=False) -class Attribute(XXXX): +class Attribute(Argument): """Attribute class.""" _node: Assign_ diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index 4d0f3bc3..bca4150d 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -1,28 +1,18 @@ """Parse docstring.""" from __future__ import annotations -import inspect import re -from re import Pattern - -# import re -# from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -# from mkapi.utils import ( -# add_admonition, -# add_fence, -# delete_ptags, -# get_indent, -# join_without_indent, -# ) +from mkapi.utils import add_admonition, add_fence, join_without_first_indent if TYPE_CHECKING: from collections.abc import Iterator type Style = Literal["google", "numpy"] -SPLIT_SECTION_PATTERNS: dict[Style, Pattern[str]] = { +SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { "google": re.compile(r"\n\n\S"), "numpy": re.compile(r"\n\n\n|\n\n.+?\n-+\n"), } @@ -58,8 +48,10 @@ def _subsplit(doc: str, style: Style) -> list[str]: ("Returns", "Return"), ("Raises", "Raise"), ("Yields", "Yield"), - ("Warnings", "Warning", "Warns", "Warn"), - ("Notes", "Note"), + ("Warnings", "Warns"), + ("Warnings", "Warns"), + ("Note",), + ("Notes",), ] @@ -75,549 +67,157 @@ def split_section(section: str, style: Style) -> tuple[str, str]: lines = section.split("\n") if len(lines) < 2: # noqa: PLR2004 return "", section - if style == "google" and (m := re.match(r"^([A-Za-z0-9].*):$", lines[0])): - return m.group(1), inspect.cleandoc("\n".join(lines[1:])) - if style == "numpy" and (m := re.match(r"^-+?$", lines[1])): - return lines[0], inspect.cleandoc("\n".join(lines[2:])) + if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): + return lines[0][:-1], join_without_first_indent(lines[1:]) + if style == "numpy" and re.match(r"^-+?$", lines[1]): + return lines[0], join_without_first_indent(lines[2:]) return "", section def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: """Yield (section name, description) pairs by splitting the whole docstring.""" + prev_name, prev_desc = "", "" for section in _iter_sections(doc, style): - if section: - name, desc = split_section(section, style) - yield _rename_section(name), desc - - -def split_parameter(doc: str) -> Iterator[list[str]]: - """Yield a list of parameter string. - - Args: - doc: Docstring - """ - start = stop = 0 - lines = [x.rstrip() for x in doc.split("\n")] - for stop, _ in enumerate(lines, 1): - next_indent = 0 if stop == len(lines) else get_indent(lines[stop]) - if next_indent == 0: - yield lines[start:stop] - start = stop - - -# PARAMETER_PATTERN = { -# "google": re.compile(r"(.*?)\s*?\((.*?)\)"), -# "numpy": re.compile(r"([^ ]*?)\s*:\s*(.*)"), -# } - - -# def parse_parameter(lines: list[str], style: str) -> Item: -# """Return a [Item] instance corresponding to a parameter. - -# Args: -# lines: Splitted parameter docstring lines. -# style: Docstring style. `google` or `numpy`. -# """ -# if style == "google": -# name, _, line = lines[0].partition(":") -# name, parsed = name.strip(), [line.strip()] -# else: -# name, parsed = lines[0].strip(), [] -# if len(lines) > 1: -# indent = get_indent(lines[1]) -# for line in lines[1:]: -# parsed.append(line[indent:]) -# if m := re.match(PARAMETER_PATTERN[style], name): -# name, type_ = m.group(1), m.group(2) -# else: -# type_ = "" -# return Item(name, Type(type_), Inline("\n".join(parsed))) - - -# def parse_parameters(doc: str, style: str) -> list[Item]: -# """Return a list of Item.""" -# return [parse_parameter(lines, style) for lines in split_parameter(doc)] - - -# def parse_returns(doc: str, style: str) -> tuple[str, str]: -# """Return a tuple of (type, markdown).""" -# type_, lines = "", doc.split("\n") -# if style == "google": -# if ":" in lines[0]: -# type_, _, lines[0] = lines[0].partition(":") -# type_ = type_.strip() -# lines[0] = lines[0].strip() -# else: -# type_, lines = lines[0].strip(), lines[1:] -# return type_, join_without_indent(lines) - - -# def parse_section(name: str, doc: str, style: str) -> Section: -# """Return a [Section] instance.""" -# type_ = markdown = "" -# items = [] -# if name in ["Parameters", "Attributes", "Raises"]: -# items = parse_parameters(doc, style) -# elif name in ["Returns", "Yields"]: -# type_, markdown = parse_returns(doc, style) -# else: -# markdown = doc -# return Section(name, markdown, items, Type(type_)) + if not section: + continue + name, desc = split_section(section, style) + if not desc: + continue + name = _rename_section(name) + if prev_name == name == "": # continuous 'plain' section. + prev_desc = f"{prev_desc}\n\n{desc}" if prev_desc else desc + continue + elif prev_name == "" and name != "" and prev_desc: + yield prev_name, prev_desc + yield name, desc + prev_name, prev_desc = name, "" + if prev_desc: + yield "", prev_desc + + +SPLIT_ITEM_PATTERN = re.compile(r"\n\S") + + +def _iter_items(section: str) -> Iterator[str]: + """Yield items for Parameters, Attributes, and Raises sections.""" + start = 0 + for m in SPLIT_ITEM_PATTERN.finditer(section): + yield section[start : m.start()].strip() + start = m.start() + yield section[start:].strip() -# def postprocess_sections(sections: list[Section]) -> None: -# for section in sections: -# if section.name in ["Note", "Notes", "Warning", "Warnings"]: -# markdown = add_admonition(section.name, section.markdown) -# if sections and sections[-1].name == "": -# sections[-1].markdown += "\n\n" + markdown -# continue -# section.name = "" -# section.markdown = markdown +SPLIT_NAME_TYPE_DESC_PATTEN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") -# def parse_docstring(doc: str) -> Docstring: -# """Return a [Docstring]) instance.""" -# if not doc: -# return Docstring() -# sections = [parse_section(*section_args) for section_args in split_section(doc)] -# postprocess_sections(sections) -# return Docstring(sections) +def _split_item_google(lines: list[str]) -> tuple[str, str, str]: + if m := re.match(SPLIT_NAME_TYPE_DESC_PATTEN, lines[0]): + name, type_, desc = m.groups() + elif ":" in lines[0]: + name, desc = lines[0].split(":", maxsplit=1) + type_ = "" + else: + name, type_, desc = lines[0], "", "" + return name, type_, "\n".join([desc.strip(), *lines[1:]]) -# @dataclass -# class Section(Base): -# """Section in docstring. - -# Args: -# items: List for Arguments, Attributes, or Raises sections, *etc.* -# type: Type of self. - -# Examples: -# >>> items = [Item("x"), Item("[y][a]"), Item("z")] -# >>> section = Section("Parameters", items=items) -# >>> section -# Section('Parameters', num_items=3) -# >>> list(section) -# [Item('[y][a]', '')] -# """ - -# items: list[Item] = field(default_factory=list) -# type: Type = field(default_factory=Type) # noqa: A003 - -# def __post_init__(self) -> None: -# if self.markdown: -# self.markdown = add_fence(self.markdown) +def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: + if ":" in lines[0]: + name, type_ = lines[0].split(":", maxsplit=1) + else: + name, type_ = lines[0], "" + return name.strip(), type_.strip(), "\n".join(lines[1:]) -# def __repr__(self) -> str: -# class_name = self.__class__.__name__ -# return f"{class_name}({self.name!r}, num_items={len(self.items)})" - -# def __bool__(self) -> bool: -# """Return True if the number of items is larger than 0.""" -# return len(self.items) > 0 - -# def __iter__(self) -> Iterator[Base]: -# """Yield a Base_ instance that has non empty Markdown.""" -# yield from self.type -# if self.markdown: -# yield self -# for item in self.items: -# yield from item - -# def __getitem__(self, name: str) -> Item: -# """Return an Item_ instance whose name is equal to `name`. - -# If there is no Item instance, a Item instance is newly created. - -# Args: -# name: Item name. - -# Examples: -# >>> section = Section("", items=[Item("x")]) -# >>> section["x"] -# Item('x', '') -# >>> section["y"] -# Item('y', '') -# >>> section.items -# [Item('x', ''), Item('y', '')] -# """ -# for item in self.items: -# if item.name == name: -# return item -# item = Item(name) -# self.items.append(item) -# return item - -# def __delitem__(self, name: str) -> None: -# """Delete an Item_ instance whose name is equal to `name`. - -# Args: -# name: Item name. -# """ -# for k, item in enumerate(self.items): -# if item.name == name: -# del self.items[k] -# return -# msg = f"name not found: {name}" -# raise KeyError(msg) -# def __contains__(self, name: str) -> bool: -# """Return True if there is an [Item] instance whose name is equal to `name`. - -# Args: -# name: Item name. -# """ -# return any(item.name == name for item in self.items) - -# def set_item(self, item: Item, *, force: bool = False) -> None: -# """Set an [Item]. - -# Args: -# item: Item instance. -# force: If True, overwrite self regardless of existing item. - -# Examples: -# >>> items = [Item("x", "int"), Item("y", "str", "y")] -# >>> section = Section("Parameters", items=items) -# >>> section.set_item(Item("x", "float", "X")) -# >>> section["x"].to_tuple() -# ('x', 'int', 'X') -# >>> section.set_item(Item("y", "int", "Y"), force=True) -# >>> section["y"].to_tuple() -# ('y', 'int', 'Y') -# >>> section.set_item(Item("z", "float", "Z")) -# >>> [item.name for item in section.items] -# ['x', 'y', 'z'] - -# See Also: -# * Section.update_ -# """ -# for k, x in enumerate(self.items): -# if x.name == item.name: -# self.items[k].update(item, force=force) -# return -# self.items.append(item.copy()) - -# def update(self, section: Section, *, force: bool = False) -> None: -# """Update items. - -# Args: -# section: Section instance. -# force: If True, overwrite items of self regardless of existing value. - -# Examples: -# >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) -# >>> s2 = Section("Parameters", items=[Item("a", "i", "A"), Item("x", "d")]) -# >>> s1.update(s2) -# >>> s1["a"].to_tuple() -# ('a', 's', 'A') -# >>> s1["x"].to_tuple() -# ('x', 'd', '') -# >>> s1.update(s2, force=True) -# >>> s1["a"].to_tuple() -# ('a', 'i', 'A') -# >>> s1.items -# [Item('a', 'i'), Item('b', 'f'), Item('x', 'd')] -# """ -# for item in section.items: -# self.set_item(item, force=force) - -# def merge(self, section: Section, *, force: bool = False) -> Section: -# """Return a merged Section. - -# Examples: -# >>> s1 = Section("Parameters", items=[Item("a", "s"), Item("b", "f")]) -# >>> s2 = Section("Parameters", items=[Item("a", "i"), Item("c", "d")]) -# >>> s3 = s1.merge(s2) -# >>> s3.items -# [Item('a', 's'), Item('b', 'f'), Item('c', 'd')] -# >>> s3 = s1.merge(s2, force=True) -# >>> s3.items -# [Item('a', 'i'), Item('b', 'f'), Item('c', 'd')] -# >>> s3 = s2.merge(s1) -# >>> s3.items -# [Item('a', 'i'), Item('c', 'd'), Item('b', 'f')] -# """ -# if section.name != self.name: -# msg = f"Different name: {self.name} != {section.name}." -# raise ValueError(msg) -# merged = Section(self.name) -# for item in self.items: -# merged.set_item(item) -# for item in section.items: -# merged.set_item(item, force=force) -# return merged - -# def copy(self) -> Self: -# """Return a copy of the instace. - -# Examples: -# >>> s = Section("E", "markdown", [Item("a", "s"), Item("b", "i")]) -# >>> s.copy() -# Section('E', num_items=2) -# """ -# items = [item.copy() for item in self.items] -# return self.__class__(self.name, self.markdown, items=items) - - -# SECTION_ORDER = ["Bases", "", "Parameters", "Attributes", "Returns", "Yields", "Raises"] +def split_item(item: str, style: Style) -> tuple[str, str, str]: + """Return a tuple of (name, type, description).""" + lines = [line.strip() for line in item.split("\n")] + if style == "google": + return _split_item_google(lines) + return _split_item_numpy(lines) + + +def iter_items(section: str, style: Style) -> Iterator[tuple[str, str, str]]: + """Yiled a tuple of (name, type, description) of item.""" + for item in _iter_items(section): + yield split_item(item, style) + + +def parse_return(section: str, style: Style) -> tuple[str, str]: + """Return a tuple of (type, description) for Returns and Yields section.""" + lines = section.split("\n") + if style == "google" and ":" in lines[0]: + type_, desc = lines[0].split(":", maxsplit=1) + return type_.strip(), "\n".join([desc.strip(), *lines[1:]]) + if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): + return lines[0], join_without_first_indent(lines[1:]) + return "", section + + +def parse_attribute(docstring: str) -> tuple[str, str]: + """Return a tuple of (type, description) for Attribute docstring.""" + return parse_return(docstring, "google") # @dataclass -# class Docstring: -# """Docstring of an object. - -# Args: -# sections: List of [Section] instance. -# type: [Type] for Returns or Yields sections. - -# Examples: -# Empty docstring: -# >>> docstring = Docstring() -# >>> assert not docstring - -# Docstring with 3 sections: -# >>> default = Section("", markdown="Default") -# >>> parameters = Section("Parameters", items=[Item("a"), Item("[b][!a]")]) -# >>> returns = Section("Returns", markdown="Results") -# >>> docstring = Docstring([default, parameters, returns]) -# >>> docstring -# Docstring(num_sections=3) - -# `Docstring` is iterable: -# >>> list(docstring) -# [Section('', num_items=0), Item('[b][!a]', ''), Section('Returns', num_items=0)] - -# Indexing: -# >>> docstring["Parameters"].items[0].name -# 'a' - -# Section ordering: -# >>> docstring = Docstring() -# >>> _ = docstring[""] -# >>> _ = docstring["Todo"] -# >>> _ = docstring["Attributes"] -# >>> _ = docstring["Parameters"] -# >>> [section.name for section in docstring.sections] -# ['', 'Parameters', 'Attributes', 'Todo'] -# """ - -# sections: list[Section] = field(default_factory=list) -# type: Type = field(default_factory=Type) # noqa: A003 +# class Base: +# """Base class.""" + +# name: str +# """Name of item.""" +# docstring: str | None +# """Docstring of item.""" # def __repr__(self) -> str: -# class_name = self.__class__.__name__ -# num_sections = len(self.sections) -# return f"{class_name}(num_sections={num_sections})" - -# def __bool__(self) -> bool: -# """Return True if the number of sections is larger than 0.""" -# return len(self.sections) > 0 - -# def __iter__(self) -> Iterator[Base]: -# """Yield [Base]() instance.""" -# for section in self.sections: -# yield from section - -# def __getitem__(self, name: str) -> Section: -# """Return a [Section]() instance whose name is equal to `name`. - -# If there is no Section instance, a Section instance is newly created. - -# Args: -# name: Section name. -# """ -# for section in self.sections: -# if section.name == name: -# return section -# section = Section(name) -# self.set_section(section) -# return section +# return f"{self.__class__.__name__}({self.name!r})" -# def __contains__(self, name: str) -> bool: -# """Return True if there is a [Section]() instance whose name is equal to `name`. - -# Args: -# name: Section name. -# """ -# return any(section.name == name for section in self.sections) - -# def set_section( -# self, -# section: Section, -# *, -# force: bool = False, -# copy: bool = False, -# replace: bool = False, -# ) -> None: -# """Set a [Section]. - -# Args: -# section: Section instance. -# force: If True, overwrite self regardless of existing seciton. -# copy: If True, section is copied. -# replace: If True,section is replaced. - -# Examples: -# >>> items = [Item("x", "int"), Item("y", "str", "y")] -# >>> s1 = Section('Attributes', items=items) -# >>> items = [Item("x", "str", "X"), Item("z", "str", "z")] -# >>> s2 = Section("Attributes", items=items) -# >>> doc = Docstring([s1]) -# >>> doc.set_section(s2) -# >>> doc["Attributes"]["x"].to_tuple() -# ('x', 'int', 'X') -# >>> doc["Attributes"]["z"].to_tuple() -# ('z', 'str', 'z') -# >>> doc.set_section(s2, force=True) -# >>> doc["Attributes"]["x"].to_tuple() -# ('x', 'str', 'X') -# >>> items = [Item("x", "X", "str"), Item("z", "z", "str")] -# >>> s3 = Section("Parameters", items=items) -# >>> doc.set_section(s3) -# >>> doc.sections -# [Section('Parameters', num_items=2), Section('Attributes', num_items=3)] -# """ -# name = section.name -# for k, x in enumerate(self.sections): -# if x.name == name: -# if replace: -# self.sections[k] = section -# else: -# self.sections[k].update(section, force=force) -# return -# if copy: -# section = section.copy() -# if name not in SECTION_ORDER: -# self.sections.append(section) -# return -# order = SECTION_ORDER.index(name) -# for k, x in enumerate(self.sections): -# if x.name not in SECTION_ORDER: -# self.sections.insert(k, section) -# return -# order_ = SECTION_ORDER.index(x.name) -# if order < order_: -# self.sections.insert(k, section) -# return -# self.sections.append(section) - - -# def parse_bases(doc: Docstring, obj: object) -> None: -# """Parse base classes to create a Base(s) line.""" -# if not inspect.isclass(obj) or not hasattr(obj, "mro"): -# return -# objs = get_mro(obj)[1:] -# if not objs: -# return -# types = [get_link(obj_, include_module=True) for obj_ in objs] -# items = [Item(type=Type(type_)) for type_ in types if type_] -# doc.set_section(Section("Bases", items=items)) - - -# def parse_source(doc: Docstring, obj: object) -> None: -# """Parse parameters' docstring to inspect type and description from source. - -# Examples: -# >>> from mkapi.core.base import Base -# >>> doc = Docstring() -# >>> parse_source(doc, Base) -# >>> section = doc["Parameters"] -# >>> section["name"].to_tuple() -# ('name', 'str, optional', 'Name of self.') -# >>> section = doc["Attributes"] -# >>> section["html"].to_tuple() -# ('html', 'str', 'HTML output after conversion.') -# """ -# signature = get_signature(obj) -# name = "Parameters" -# section: Section = signature[name] -# if name in doc: -# section = section.merge(doc[name], force=True) -# if section: -# doc.set_section(section, replace=True) - -# name = "Attributes" -# section: Section = signature[name] -# if name not in doc and not section: -# return -# doc[name].update(section) -# if is_dataclass(obj) and "Parameters" in doc: -# for item in doc["Parameters"].items: -# if item.name in section: -# doc[name].set_item(item) - - -# def postprocess_parameters(doc: Docstring, signature: Signature) -> None: -# if "Parameters" not in doc: -# return -# for item in doc["Parameters"].items: -# description = item.description -# if "{default}" in description.name and item.name in signature: -# default = signature.defaults[item.name] -# description.markdown = description.name.replace("{default}", default) - - -# def postprocess_returns(doc: Docstring, signature: Signature) -> None: -# for name in ["Returns", "Yields"]: -# if name in doc: -# section = doc[name] -# if not section.type: -# section.type = Type(getattr(signature, name.lower())) - - -# def postprocess_sections(doc: Docstring, obj: object) -> None: -# sections: list[Section] = [] -# for section in doc.sections: -# if section.name not in ["Example", "Examples"]: -# for base in section: -# base.markdown = replace_link(obj, base.markdown) -# if section.name in ["Note", "Notes", "Warning", "Warnings"]: -# markdown = add_admonition(section.name, section.markdown) -# if sections and sections[-1].name == "": -# sections[-1].markdown += "\n\n" + markdown -# continue -# section.name = "" -# section.markdown = markdown -# sections.append(section) -# doc.sections = sections +# @dataclass +# class Nodes[T]: +# """Collection of [Node] instance.""" + +# items: list[T] + +# def __getitem__(self, index: int | str) -> T: +# if isinstance(index, int): +# return self.items[index] +# names = [item.name for item in self.items] # type: ignore # noqa: PGH003 +# return self.items[names.index(index)] -# def set_docstring_type(doc: Docstring, signature: Signature, obj: object) -> None: -# from mkapi.core.node import get_kind +# def __getattr__(self, name: str) -> T: +# return self[name] -# if "Returns" not in doc and "Yields" not in doc: -# if get_kind(obj) == "generator": -# doc.type = Type(signature.yields) -# else: -# doc.type = Type(signature.returns) +# def __iter__(self) -> Iterator[T]: +# return iter(self.items) +# def __contains__(self, name: str) -> bool: +# return any(name == item.name for item in self.items) # type: ignore # noqa: PGH003 -# def postprocess_docstring(doc: Docstring, obj: object) -> None: -# """Docstring prostprocess.""" -# parse_bases(doc, obj) -# parse_source(doc, obj) +# def __repr__(self) -> str: +# names = ", ".join(f"{item.name!r}" for item in self.items) # type: ignore # noqa: PGH003 +# return f"{self.__class__.__name__}({names})" -# if not callable(obj): -# return -# signature = get_signature(obj) -# if not signature.signature: -# return +# @dataclass(repr=False) +# class Import(Node): +# """Import class.""" -# postprocess_parameters(doc, signature) -# postprocess_returns(doc, signature) -# postprocess_sections(doc, obj) -# set_docstring_type(doc, signature, obj) +# _node: ast.Import | ImportFrom +# fullanme: str -# def get_docstring(obj: object) -> Docstring: -# """Return a [Docstring]) instance.""" -# doc = inspect.getdoc(obj) -# if not doc: -# return Docstring() -# sections = [get_section(*section_args) for section_args in split_section(doc)] -# docstring = Docstring(sections) -# postprocess_docstring(docstring, obj) -# return docstring +# @dataclass +# class Imports(Nodes[Import]): +# """Imports class.""" + + +# self.markdown = add_fence(self.markdown) +# def postprocess_sections(sections: list[Section]) -> None: +# for section in sections: +# if section.name in ["Note", "Notes", "Warning", "Warnings"]: +# markdown = add_admonition(section.name, section.markdown) +# if sections and sections[-1].name == "": +# sections[-1].markdown += "\n\n" + markdown +# continue +# section.name = "" +# section.markdown = markdown diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 121edba8..9e4a8042 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -46,25 +46,6 @@ def find_submodule_names(name: str) -> list[str]: return sorted(names, key=lambda name: not _is_package(name)) -def split_type(markdown: str) -> tuple[str, str]: - """Return a tuple of (type, markdown) splitted by a colon. - - Examples: - >>> split_type("int : Integer value.") - ('int', 'Integer value.') - >>> split_type("abc") - ('', 'abc') - >>> split_type("") - ('', '') - """ - line = markdown.split("\n", maxsplit=1)[0] - if (index := line.find(":")) != -1: - type_ = line[:index].strip() - markdown = markdown[index + 1 :].strip() - return type_, markdown - return "", markdown - - def delete_ptags(html: str) -> str: """Return HTML without

tag. @@ -95,31 +76,31 @@ def get_indent(line: str) -> int: return -1 -def join_without_indent( +def join_without_first_indent( lines: list[str] | str, start: int = 0, stop: int | None = None, ) -> str: - r"""Return a joint string without indent. + r"""Return a joint string without first indent. Examples: - >>> join_without_indent(["abc", "def"]) + >>> join_without_first_indent(["abc", "def"]) 'abc\ndef' - >>> join_without_indent([" abc", " def"]) + >>> join_without_first_indent([" abc", " def"]) 'abc\ndef' - >>> join_without_indent([" abc", " def ", ""]) + >>> join_without_first_indent([" abc", " def ", ""]) 'abc\n def' - >>> join_without_indent([" abc", " def", " ghi"]) + >>> join_without_first_indent([" abc", " def", " ghi"]) 'abc\n def\n ghi' - >>> join_without_indent([" abc", " def", " ghi"], stop=2) + >>> join_without_first_indent([" abc", " def", " ghi"], stop=2) 'abc\n def' - >>> join_without_indent([]) + >>> join_without_first_indent([]) '' """ if not lines: return "" if isinstance(lines, str): - return join_without_indent(lines.split("\n")) + return join_without_first_indent(lines.split("\n")) indent = get_indent(lines[start]) return "\n".join(line[indent:] for line in lines[start:stop]).strip() @@ -135,11 +116,11 @@ def _splitter(text: str) -> Iterator[str]: start = stop - 1 in_code = True elif not line.strip() and in_code: - yield join_without_indent(lines, start, stop) + yield join_without_first_indent(lines, start, stop) start = stop in_code = False if start < len(lines): - yield join_without_indent(lines, start, len(lines)) + yield join_without_first_indent(lines, start, len(lines)) def add_fence(text: str) -> str: diff --git a/tests/docstring/test_examples.py b/tests/docstring/test_examples.py deleted file mode 100644 index 27fa55a7..00000000 --- a/tests/docstring/test_examples.py +++ /dev/null @@ -1,73 +0,0 @@ -import pytest - -from mkapi.ast import Module -from mkapi.docstring import iter_sections, split_section - - -def test_split_section_heading(): - f = split_section - for style in ["google", "numpy"]: - assert f("A", style) == ("", "A") # type: ignore - assert f("A:\n a\n b", "google") == ("A", "a\nb") - assert f("A\n a\n b", "google") == ("", "A\n a\n b") - assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") - assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") - assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") - - -def test_iter_sections_short(): - sections = list(iter_sections("", "google")) - assert sections == [] - sections = list(iter_sections("x", "google")) - assert sections == [("", "x")] - sections = list(iter_sections("x\n", "google")) - assert sections == [("", "x")] - sections = list(iter_sections("x\n\n", "google")) - assert sections == [("", "x")] - - -@pytest.mark.parametrize("style", ["google", "numpy"]) -def test_iter_sections_google(style, google: Module, numpy: Module): - doc = google.docstring if style == "google" else numpy.docstring - assert isinstance(doc, str) - sections = list(iter_sections(doc, style)) - if style == "google": - assert len(sections) == 7 - assert sections[0][1].startswith("Example Google") - assert sections[0][1].endswith("docstrings.") - assert sections[1][1].startswith("This module") - assert sections[1][1].endswith("indented text.") - assert sections[2][0] == "Examples" - assert sections[2][1].startswith("Examples can be") - assert sections[2][1].endswith("google.py") - assert sections[3][1].startswith("Section breaks") - assert sections[3][1].endswith("section starts.") - assert sections[4][0] == "Attributes" - assert sections[4][1].startswith("module_level_") - assert sections[4][1].endswith("with it.") - assert sections[5][0] == "Todo" - assert sections[5][1].startswith("* For") - assert sections[5][1].endswith("extension") - assert sections[6][1].startswith("..") - assert sections[6][1].endswith(".html") - else: - assert len(sections) == 8 - assert sections[0][1].startswith("Example NumPy") - assert sections[0][1].endswith("docstrings.") - assert sections[1][1].startswith("This module") - assert sections[1][1].endswith("equal length.") - assert sections[2][0] == "Examples" - assert sections[2][1].startswith("Examples can be") - assert sections[2][1].endswith("numpy.py") - assert sections[3][1].startswith("Section breaks") - assert sections[3][1].endswith("be\nindented:") - assert sections[4][0] == "Notes" - assert sections[4][1].startswith("This is an") - assert sections[4][1].endswith("surrounding text.") - assert sections[5][1].startswith("If a section") - assert sections[5][1].endswith("unindented text.") - assert sections[6][0] == "Attributes" - assert sections[6][1].startswith("module_level") - assert sections[6][1].endswith("with it.") - assert sections[7][1].startswith("..") - assert sections[7][1].endswith(".rst.txt") diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py new file mode 100644 index 00000000..9f09f10a --- /dev/null +++ b/tests/docstring/test_google.py @@ -0,0 +1,131 @@ +from mkapi.ast import Module +from mkapi.docstring import ( + _iter_items, + iter_items, + iter_sections, + parse_attribute, + parse_return, + split_item, + split_section, +) + + +def test_split_section(): + f = split_section + for style in ["google", "numpy"]: + assert f("A", style) == ("", "A") # type: ignore + assert f("A:\n a\n b", "google") == ("A", "a\nb") + assert f("A\n a\n b", "google") == ("", "A\n a\n b") + assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") + assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") + assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +def test_iter_sections_short(): + sections = list(iter_sections("", "google")) + assert sections == [] + sections = list(iter_sections("x", "google")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n", "google")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n\n", "google")) + assert sections == [("", "x")] + + +def test_iter_sections(google: Module): + doc = google.docstring + assert isinstance(doc, str) + sections = list(iter_sections(doc, "google")) + assert len(sections) == 6 + assert sections[0][1].startswith("Example Google") + assert sections[0][1].endswith("indented text.") + assert sections[1][0] == "Examples" + assert sections[1][1].startswith("Examples can be") + assert sections[1][1].endswith("google.py") + assert sections[2][1].startswith("Section breaks") + assert sections[2][1].endswith("section starts.") + assert sections[3][0] == "Attributes" + assert sections[3][1].startswith("module_level_") + assert sections[3][1].endswith("with it.") + assert sections[4][0] == "Todo" + assert sections[4][1].startswith("* For") + assert sections[4][1].endswith("extension") + assert sections[5][1].startswith("..") + assert sections[5][1].endswith(".html") + + +def test_iter_items(google: Module): + doc = google.functions.module_level_function.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "google"))[1][1] + items = list(_iter_items(section)) + assert len(items) == 4 + assert items[0].startswith("param1") + assert items[1].startswith("param2") + assert items[2].startswith("*args") + assert items[3].startswith("**kwargs") + doc = google.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "google"))[3][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + + +def test_split_item(google): + doc = google.functions.module_level_function.docstring + assert isinstance(doc, str) + sections = list(iter_sections(doc, "google")) + section = sections[1][1] + items = list(_iter_items(section)) + x = split_item(items[0], "google") + assert x == ("param1", "int", "The first parameter.") + x = split_item(items[1], "google") + assert x[:2] == ("param2", ":obj:`str`, optional") + assert x[2].endswith("should be indented.") + x = split_item(items[2], "google") + assert x == ("*args", "", "Variable length argument list.") + section = sections[3][1] + items = list(_iter_items(section)) + x = split_item(items[0], "google") + assert x[:2] == ("AttributeError", "") + assert x[2].endswith("the interface.") + + +def test_iter_items_class(google): + doc = google.classes.ExampleClass.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "google"))[1][1] + x = list(iter_items(section, "google")) + assert x[0] == ("attr1", "str", "Description of `attr1`.") + assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") + doc = google.classes.ExampleClass.functions["__init__"].docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "google"))[2][1] + x = list(iter_items(section, "google")) + assert x[0] == ("param1", "str", "Description of `param1`.") + assert x[1][2] == "Description of `param2`. Multiple\nlines are supported." + + +def test_get_return(google): + doc = google.functions.module_level_function.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "google"))[2][1] + x = parse_return(section, "google") + assert x[0] == "bool" + assert x[1].startswith("True if") + assert x[1].endswith(" }") + + +def test_parse_attribute(google): + doc = google.classes.ExampleClass.functions.readonly_property.docstring + assert isinstance(doc, str) + x = parse_attribute(doc) + assert x[0] == "str" + assert x[1] == "Properties should be documented in their getter method." + doc = google.classes.ExampleClass.functions.readwrite_property.docstring + assert isinstance(doc, str) + x = parse_attribute(doc) + assert x[0] == "list(str)" + assert x[1].startswith("Properties with both") + assert x[1].endswith("mentioned here.") diff --git a/tests/docstring/test_numpy.py b/tests/docstring/test_numpy.py new file mode 100644 index 00000000..e743c239 --- /dev/null +++ b/tests/docstring/test_numpy.py @@ -0,0 +1,128 @@ +from mkapi.ast import Module +from mkapi.docstring import ( + _iter_items, + iter_items, + iter_sections, + parse_attribute, + parse_return, + split_item, + split_section, +) + + +def test_split_section(): + f = split_section + assert f("A", "numpy") == ("", "A") # type: ignore + assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") + assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") + assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +def test_iter_sections_short(): + sections = list(iter_sections("", "numpy")) + assert sections == [] + sections = list(iter_sections("x", "numpy")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n", "numpy")) + assert sections == [("", "x")] + sections = list(iter_sections("x\n\n", "numpy")) + assert sections == [("", "x")] + + +def test_iter_sections(numpy: Module): + doc = numpy.docstring + assert isinstance(doc, str) + sections = list(iter_sections(doc, "numpy")) + assert len(sections) == 7 + assert sections[0][1].startswith("Example NumPy") + assert sections[0][1].endswith("equal length.") + assert sections[1][0] == "Examples" + assert sections[1][1].startswith("Examples can be") + assert sections[1][1].endswith("numpy.py") + assert sections[2][1].startswith("Section breaks") + assert sections[2][1].endswith("be\nindented:") + assert sections[3][0] == "Notes" + assert sections[3][1].startswith("This is an") + assert sections[3][1].endswith("surrounding text.") + assert sections[4][1].startswith("If a section") + assert sections[4][1].endswith("unindented text.") + assert sections[5][0] == "Attributes" + assert sections[5][1].startswith("module_level") + assert sections[5][1].endswith("with it.") + assert sections[6][1].startswith("..") + assert sections[6][1].endswith(".rst.txt") + + +def test_iter_items(numpy: Module): + doc = numpy.functions.module_level_function.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "numpy"))[1][1] + items = list(_iter_items(section)) + assert len(items) == 4 + assert items[0].startswith("param1") + assert items[1].startswith("param2") + assert items[2].startswith("*args") + assert items[3].startswith("**kwargs") + doc = numpy.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "numpy"))[5][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + + +def test_split_item(numpy): + doc = numpy.functions.module_level_function.docstring + assert isinstance(doc, str) + sections = list(iter_sections(doc, "numpy")) + items = list(_iter_items(sections[1][1])) + x = split_item(items[0], "numpy") + assert x == ("param1", "int", "The first parameter.") + x = split_item(items[1], "numpy") + assert x[:2] == ("param2", ":obj:`str`, optional") + assert x[2] == "The second parameter." + x = split_item(items[2], "numpy") + assert x == ("*args", "", "Variable length argument list.") + items = list(_iter_items(sections[3][1])) + x = split_item(items[0], "numpy") + assert x[:2] == ("AttributeError", "") + assert x[2].endswith("the interface.") + + +def test_iter_items_class(numpy): + doc = numpy.classes.ExampleClass.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "numpy"))[1][1] + x = list(iter_items(section, "numpy")) + assert x[0] == ("attr1", "str", "Description of `attr1`.") + assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") + doc = numpy.classes.ExampleClass.functions["__init__"].docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "numpy"))[2][1] + x = list(iter_items(section, "numpy")) + assert x[0] == ("param1", "str", "Description of `param1`.") + assert x[1][2] == "Description of `param2`. Multiple\nlines are supported." + + +def test_get_return(numpy): + doc = numpy.functions.module_level_function.docstring + assert isinstance(doc, str) + section = list(iter_sections(doc, "numpy"))[2][1] + x = parse_return(section, "numpy") + assert x[0] == "bool" + assert x[1].startswith("True if") + assert x[1].endswith(" }") + + +def test_parse_attribute(numpy): + doc = numpy.classes.ExampleClass.functions.readonly_property.docstring + assert isinstance(doc, str) + x = parse_attribute(doc) + assert x[0] == "str" + assert x[1] == "Properties should be documented in their getter method." + doc = numpy.classes.ExampleClass.functions.readwrite_property.docstring + assert isinstance(doc, str) + x = parse_attribute(doc) + assert x[0] == "list(str)" + assert x[1].startswith("Properties with both") + assert x[1].endswith("mentioned here.") From 3eda03d5c5bc4a8adbad3a33d18a2642bc229d6d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 3 Jan 2024 22:43:17 +0900 Subject: [PATCH 048/148] Delete container --- src/mkapi/ast.py | 343 +++++++++++++-------------------- src/mkapi/base.py | 22 +++ src/mkapi/docstring.py | 4 + tests/ast/test_args.py | 22 +-- tests/ast/test_attrs.py | 8 +- tests/ast/test_defs.py | 2 +- tests/ast/test_eval.py | 2 +- tests/ast/test_node.py | 16 +- tests/docstring/test_google.py | 14 +- tests/docstring/test_numpy.py | 14 +- 10 files changed, 192 insertions(+), 255 deletions(-) create mode 100644 src/mkapi/base.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 86f16f8a..f168a61b 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -4,6 +4,7 @@ import ast from ast import ( AnnAssign, + Assign, AsyncFunctionDef, ClassDef, Constant, @@ -11,6 +12,8 @@ FunctionDef, ImportFrom, Name, + NodeTransformer, + Raise, TypeAlias, ) from dataclasses import dataclass @@ -25,10 +28,10 @@ from collections.abc import Iterator from inspect import _ParameterKind +type Import_ = ast.Import | ImportFrom type FunctionDef_ = AsyncFunctionDef | FunctionDef type Def = FunctionDef_ | ClassDef -type Assign_ = ast.Assign | AnnAssign | TypeAlias -type Doc = ast.Module | Def | Assign_ +type Assign_ = Assign | AnnAssign | TypeAlias module_cache: dict[str, tuple[float, ast.Module]] = {} @@ -51,7 +54,23 @@ def get_module_node(name: str) -> ast.Module: return node -def iter_import_nodes(node: AST) -> Iterator[ast.Import | ImportFrom]: +@dataclass +class Node: # noqa: D101 + _node: ast.AST + name: str + docstring: str | None + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name!r})" + + +@dataclass(repr=False) +class Import(Node): # noqa: D101 + _node: ast.Import | ImportFrom + fullname: str + + +def iter_import_nodes(node: AST) -> Iterator[Import_]: """Yield import nodes.""" for child in ast.iter_child_nodes(node): if isinstance(child, ast.Import | ImportFrom): @@ -60,30 +79,25 @@ def iter_import_nodes(node: AST) -> Iterator[ast.Import | ImportFrom]: yield from iter_import_nodes(child) -def iter_import_node_names( - node: ast.Module | Def, -) -> Iterator[tuple[ast.Import | ImportFrom, str, str]]: +def iter_imports(node: ast.Module) -> Iterator[Import]: """Yield import nodes and names.""" for child in iter_import_nodes(node): from_module = f"{child.module}." if isinstance(child, ImportFrom) else "" for alias in child.names: name = alias.asname or alias.name fullname = f"{from_module}{alias.name}" - yield child, name, fullname + yield Import(child, name, None, fullname) -def get_assign_name(node: Assign_) -> str | None: - """Return the name of the assign node.""" - if isinstance(node, AnnAssign) and isinstance(node.target, Name): - return node.target.id - if isinstance(node, ast.Assign) and isinstance(node.targets[0], Name): - return node.targets[0].id - if isinstance(node, TypeAlias) and isinstance(node.name, Name): - return node.name.id - return None +@dataclass(repr=False) +class Attribute(Node): # noqa: D101 + _node: Assign_ + type: ast.expr | None # # noqa: A003 + default: ast.expr | None + type_params: list[ast.type_param] | None -def _get_docstring(node: AST) -> str | None: +def _get_pseudo_docstring(node: AST) -> str | None: if not isinstance(node, Expr) or not isinstance(node.value, Constant): return None doc = node.value.value @@ -94,14 +108,14 @@ def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: """Yield assign nodes.""" assign_node: Assign_ | None = None for child in ast.iter_child_nodes(node): - if isinstance(child, AnnAssign | ast.Assign | TypeAlias): + if isinstance(child, AnnAssign | Assign | TypeAlias): if assign_node: yield assign_node child.__doc__ = None assign_node = child else: if assign_node: - assign_node.__doc__ = _get_docstring(child) + assign_node.__doc__ = _get_pseudo_docstring(child) yield assign_node assign_node = None if assign_node: @@ -109,85 +123,43 @@ def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: yield assign_node -def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: - """Yield definition nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): - yield child - - -def get_docstring(node: Doc) -> str | None: - """Return the docstring for the given node or None if no docstring can be found.""" - if isinstance(node, AsyncFunctionDef | FunctionDef | ClassDef | ast.Module): - return ast.get_docstring(node) - if isinstance(node, ast.Assign | AnnAssign | TypeAlias): - return node.__doc__ - msg = f"{node.__class__.__name__!r} can't have docstrings" - raise TypeError(msg) - - -@dataclass -class Node: - """Node class.""" - - _node: AST - name: str - """Name of item.""" - docstring: str | None - """Docstring of item.""" - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name!r})" - - -@dataclass -class Nodes[T]: - """Collection of [Node] instance.""" - - items: list[T] - - def __getitem__(self, index: int | str) -> T: - if isinstance(index, int): - return self.items[index] - names = [item.name for item in self.items] # type: ignore # noqa: PGH003 - return self.items[names.index(index)] +def get_assign_name(node: Assign_) -> str | None: + """Return the name of the assign node.""" + if isinstance(node, AnnAssign) and isinstance(node.target, Name): + return node.target.id + if isinstance(node, Assign) and isinstance(node.targets[0], Name): + return node.targets[0].id + if isinstance(node, TypeAlias) and isinstance(node.name, Name): + return node.name.id + return None - def __getattr__(self, name: str) -> T: - return self[name] - def __iter__(self) -> Iterator[T]: - return iter(self.items) +def get_type(node: Assign_) -> ast.expr | None: + """Return annotation.""" + if isinstance(node, AnnAssign): + return node.annotation + if isinstance(node, TypeAlias): + return node.value + return None - def __contains__(self, name: str) -> bool: - return any(name == item.name for item in self.items) # type: ignore # noqa: PGH003 - def __repr__(self) -> str: - names = ", ".join(f"{item.name!r}" for item in self.items) # type: ignore # noqa: PGH003 - return f"{self.__class__.__name__}({names})" +def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: + """Yield assign nodes.""" + for assign in iter_assign_nodes(node): + if not (name := get_assign_name(assign)): + continue + type_ = get_type(assign) + value = None if isinstance(assign, TypeAlias) else assign.value + type_params = assign.type_params if isinstance(assign, TypeAlias) else None + yield Attribute(assign, name, assign.__doc__, type_, value, type_params) @dataclass(repr=False) -class Import(Node): - """Import class.""" - - _node: ast.Import | ImportFrom - fullanme: str - - -@dataclass -class Imports(Nodes[Import]): - """Imports class.""" - - -def iter_imports(node: ast.Module | ClassDef) -> Iterator[Import]: - """Yield import nodes.""" - for child, name, fullname in iter_import_node_names(node): - yield Import(child, name, None, fullname) - - -def get_imports(node: ast.Module | ClassDef) -> Imports: - """Return imports in module or class definition.""" - return Imports(list(iter_imports(node))) +class Parameter(Node): # noqa: D101 + _node: ast.arg + type: ast.expr | None # # noqa: A003 + default: ast.expr | None + kind: _ParameterKind ARGS_KIND: dict[_ParameterKind, str] = { @@ -214,27 +186,6 @@ def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: yield from args.kw_defaults -@dataclass(repr=False) -class Argument(Node): - """Argument class.""" - - type: ast.expr | None # # noqa: A003 - default: ast.expr | None - - -@dataclass(repr=False) -class Parameter(Argument): - """Parameter class.""" - - _node: ast.arg - kind: _ParameterKind - - -@dataclass -class Parameters(Nodes[Parameter]): - """Parameters class.""" - - def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: """Yield parameters from the function node.""" it = _iter_defaults(node) @@ -243,149 +194,119 @@ def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) -def get_parameters(node: FunctionDef_ | ClassDef) -> Parameters: - """Return the function parameters.""" - if isinstance(node, ClassDef): - return Parameters([]) - return Parameters(list(iter_parameters(node))) - - -@dataclass(repr=False) -class Attribute(Argument): - """Attribute class.""" - - _node: Assign_ - type_params: list[ast.type_param] | None - - @dataclass(repr=False) -class Attributes(Nodes[Attribute]): - """Attributes class.""" - - -def get_annotation(node: Assign_) -> ast.expr | None: - """Return annotation.""" - if isinstance(node, AnnAssign): - return node.annotation - if isinstance(node, TypeAlias): - return node.value - return None - - -def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: - """Yield assign nodes.""" - for assign in iter_assign_nodes(node): - if not (name := get_assign_name(assign)): - continue - ann = get_annotation(assign) - value = None if isinstance(assign, TypeAlias) else assign.value - doc = get_docstring(assign) - type_params = assign.type_params if isinstance(assign, TypeAlias) else None - yield Attribute(assign, name, doc, ann, value, type_params) - - -def get_attributes(node: ast.Module | ClassDef) -> Attributes: - """Return assigns in module or class definition.""" - return Attributes(list(iter_attributes(node))) - - -@dataclass(repr=False) -class Definition(Node): - """Definition class for function and class.""" - - parameters: Parameters +class Definition(Node): # noqa: D101 + parameters: list[Parameter] decorators: list[ast.expr] type_params: list[ast.type_param] - # raises: + raises: list[ast.expr] @dataclass(repr=False) -class Function(Definition): - """Function class.""" - +class Function(Definition): # noqa: D101 _node: FunctionDef_ returns: ast.expr | None @dataclass(repr=False) -class Class(Definition): - """Class class.""" - +class Class(Definition): # noqa: D101 _node: ClassDef bases: list[ast.expr] - attributes: Attributes - functions: Functions + attributes: list[Attribute] + classes: list[Class] + functions: list[Function] + def get(self, name: str) -> Class | Function: # noqa: D102 + names = [cls.name for cls in self.classes] + if name in names: + return self.classes[names.index(name)] + names = [func.name for func in self.functions] + if name in names: + return self.functions[names.index(name)] + raise NameError -@dataclass(repr=False) -class Functions(Nodes[Function]): - """Functions class.""" +def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: + """Yield definition nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): + yield child -@dataclass(repr=False) -class Classes(Nodes[Class]): - """Classes class.""" + +def iter_raise_nodes(node: FunctionDef_) -> Iterator[Raise]: + """Yield raise nodes.""" + for child in ast.walk(node): + if isinstance(child, Raise): + yield child def _get_def_args( - node: ClassDef | FunctionDef_, -) -> tuple[str, str | None, Parameters, list[ast.expr], list[ast.type_param]]: + node: Def, +) -> tuple[str, str | None, list[Parameter], list[ast.expr], list[ast.type_param]]: name = node.name - docstring = get_docstring(node) - arguments = get_parameters(node) + docstring = ast.get_docstring(node) + parameters = [] if isinstance(node, ClassDef) else list(iter_parameters(node)) decorators = node.decorator_list type_params = node.type_params - return name, docstring, arguments, decorators, type_params + return name, docstring, parameters, decorators, type_params def iter_definitions(node: ast.Module | ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" - for def_ in iter_definition_nodes(node): - args = _get_def_args(def_) - if isinstance(def_, ClassDef): - attrs = get_attributes(def_) - _, functions = get_definitions(def_) # ignore internal classes. - yield Class(def_, *args, def_.bases, attrs, functions) + for def_node in iter_definition_nodes(node): + args = _get_def_args(def_node) + if isinstance(def_node, ClassDef): + attrs = list(iter_attributes(def_node)) + classes, functions = get_definitions(def_node) + yield Class(def_node, *args, [], def_node.bases, attrs, classes, functions) else: - yield Function(def_, *args, def_.returns) + raises = [r.exc for r in iter_raise_nodes(def_node) if r.exc] + yield Function(def_node, *args, raises, def_node.returns) -def get_definitions(node: ast.Module | ClassDef) -> tuple[Classes, Functions]: - """Return a tuple of ([Classes], [Functions]) instances.""" +def get_definitions(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: + """Return a tuple of (list[Class], list[Function]).""" classes: list[Class] = [] functions: list[Function] = [] - for obj in iter_definitions(node): - if isinstance(obj, Class): - classes.append(obj) + for definition in iter_definitions(node): + if isinstance(definition, Class): + classes.append(definition) else: - functions.append(obj) - return Classes(classes), Functions(functions) + functions.append(definition) + return classes, functions @dataclass -class Module(Node): - """Module class.""" - - imports: Imports - attributes: Attributes - classes: Classes - functions: Functions +class Module(Node): # noqa: D101 + imports: list[Import] + attributes: list[Attribute] + classes: list[Class] + functions: list[Function] + + def get(self, name: str) -> Class | Function: # noqa: D102 + names = [cls.name for cls in self.classes] + if name in names: + return self.classes[names.index(name)] + names = [func.name for func in self.functions] + if name in names: + return self.functions[names.index(name)] + raise NameError def get_module(node: ast.Module) -> Module: """Return a [Module] instance.""" - docstring = get_docstring(node) - imports = get_imports(node) - attrs = get_attributes(node) + docstring = ast.get_docstring(node) + imports = list(iter_imports(node)) + attrs = list(iter_attributes(node)) classes, functions = get_definitions(node) return Module(node, "", docstring, imports, attrs, classes, functions) -class Transformer(ast.NodeTransformer): # noqa: D101 - def _rename(self, name: str) -> ast.Name: - return ast.Name(id=f"__mkapi__.{name}") +class Transformer(NodeTransformer): # noqa: D101 + def _rename(self, name: str) -> Name: + return Name(id=f"__mkapi__.{name}") - def visit_Name(self, node: ast.Name) -> ast.Name: # noqa: N802, D102 + def visit_Name(self, node: Name) -> Name: # noqa: N802, D102 return self._rename(node.id) def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 @@ -393,7 +314,7 @@ def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 class StringTransformer(Transformer): # noqa: D101 - def visit_Constant(self, node: ast.Constant) -> ast.Constant | ast.Name: # noqa: N802, D102 + def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 if isinstance(node.value, str): return self._rename(node.value) return node diff --git a/src/mkapi/base.py b/src/mkapi/base.py new file mode 100644 index 00000000..238109e2 --- /dev/null +++ b/src/mkapi/base.py @@ -0,0 +1,22 @@ +"""Base class.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ast + + +@dataclass +class Node: + """Node class.""" + + _node: ast.AST + name: str + """Name of item.""" + docstring: str | None + """Docstring of item.""" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name!r})" diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index bca4150d..70d3aacb 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -221,3 +221,7 @@ def parse_attribute(docstring: str) -> tuple[str, str]: # continue # section.name = "" # section.markdown = markdown +def parse_docstring(doc: str): + doc = add_fence(doc) + # if section.name in ["Note", "Notes", "Warning", "Warnings"]: + # secton = add_admonition(section.name, section.markdown) diff --git a/tests/ast/test_args.py b/tests/ast/test_args.py index 16b64108..3a0116b5 100644 --- a/tests/ast/test_args.py +++ b/tests/ast/test_args.py @@ -1,36 +1,36 @@ import ast from inspect import Parameter -from mkapi.ast import get_parameters +from mkapi.ast import iter_parameters def _get_args(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.FunctionDef) - return get_parameters(node) + return list(iter_parameters(node)) def test_get_parameters_1(): args = _get_args("def f():\n pass") - assert not args.items + assert not args args = _get_args("def f(x):\n pass") - assert args.x.type is None - assert args.x.default is None - assert args.x.kind is Parameter.POSITIONAL_OR_KEYWORD - x = _get_args("def f(x=1):\n pass").x + assert args[0].type is None + assert args[0].default is None + assert args[0].kind is Parameter.POSITIONAL_OR_KEYWORD + x = _get_args("def f(x=1):\n pass")[0] assert isinstance(x.default, ast.Constant) - x = _get_args("def f(x:str='s'):\n pass").x + x = _get_args("def f(x:str='s'):\n pass")[0] assert isinstance(x.type, ast.Name) assert x.type.id == "str" assert isinstance(x.default, ast.Constant) assert x.default.value == "s" - x = _get_args("def f(x:'X'='s'):\n pass").x + x = _get_args("def f(x:'X'='s'):\n pass")[0] assert isinstance(x.type, ast.Constant) assert x.type.value == "X" def test_get_parameters_2(): - x = _get_args("def f(x:tuple[int]=(1,)):\n pass").x + x = _get_args("def f(x:tuple[int]=(1,)):\n pass")[0] assert isinstance(x.type, ast.Subscript) assert isinstance(x.type.value, ast.Name) assert x.type.value.id == "tuple" @@ -42,7 +42,7 @@ def test_get_parameters_2(): def test_get_parameters_3(): - x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass").x + x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] assert isinstance(x.type, ast.Subscript) assert isinstance(x.type.value, ast.Name) assert x.type.value.id == "tuple" diff --git a/tests/ast/test_attrs.py b/tests/ast/test_attrs.py index a9efbc94..ed4ea316 100644 --- a/tests/ast/test_attrs.py +++ b/tests/ast/test_attrs.py @@ -1,24 +1,24 @@ import ast -from mkapi.ast import get_attributes +from mkapi.ast import iter_attributes def _get_attributes(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.ClassDef) - return get_attributes(node) + return list(iter_attributes(node)) def test_get_attributes(): src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" - x = _get_attributes(src).x + x = _get_attributes(src)[0] assert x.type is None assert isinstance(x.default, ast.Call) assert ast.unparse(x.default.func) == "f.g" assert x.docstring == "docstring" src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" assigns = _get_attributes(src) - x, y, z = assigns.items + x, y, z = assigns assert x.docstring is None assert x.default is None assert y.docstring == "docstring\na" diff --git a/tests/ast/test_defs.py b/tests/ast/test_defs.py index 3b2a21fb..11832ec2 100644 --- a/tests/ast/test_defs.py +++ b/tests/ast/test_defs.py @@ -9,5 +9,5 @@ def _get(src: str): def test_deco(): module = _get("@f(x,a=1)\nclass A:\n pass") - deco = module.classes.A.decorators[0] + deco = module.get("A").decorators[0] assert isinstance(deco, ast.Call) diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 6bf15260..73231c7e 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -65,7 +65,7 @@ def test_iter_identifiers(): def test_functions(module: Module): - func = module.functions._get_def_args # noqa: SLF001 + func = module.get("_get_def_args") type_ = func.parameters[0].type assert isinstance(type_, ast.expr) text = StringTransformer().unparse(type_) diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 000e8cfd..d583217a 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -7,8 +7,8 @@ get_module, get_module_node, iter_definition_nodes, - iter_import_node_names, iter_import_nodes, + iter_imports, ) @@ -39,8 +39,8 @@ def test_iter_import_nodes(module: Module): def test_get_import_names(module: Module): - it = iter_import_node_names(module) - names = {name: fullname for (_, name, fullname) in it} + it = iter_imports(module) + names = {im.name: im.fullname for im in it} assert "logging" in names assert names["logging"] == "logging" assert "PurePath" in names @@ -57,13 +57,3 @@ def def_nodes(module: Module): def test_iter_definition_nodes(def_nodes): assert any(node.name == "get_files" for node in def_nodes) assert any(node.name == "Files" for node in def_nodes) - - -def test_get_module(): - node = get_module_node("mkapi.ast") - module = get_module(node) - assert module.docstring - assert "_ParameterKind" in module.imports - assert module.attributes.Assign_ - assert module.classes.Module - assert module.functions.get_module diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py index 9f09f10a..854033ac 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstring/test_google.py @@ -55,7 +55,7 @@ def test_iter_sections(google: Module): def test_iter_items(google: Module): - doc = google.functions.module_level_function.docstring + doc = google.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[1][1] items = list(_iter_items(section)) @@ -73,7 +73,7 @@ def test_iter_items(google: Module): def test_split_item(google): - doc = google.functions.module_level_function.docstring + doc = google.get("module_level_function").docstring assert isinstance(doc, str) sections = list(iter_sections(doc, "google")) section = sections[1][1] @@ -93,13 +93,13 @@ def test_split_item(google): def test_iter_items_class(google): - doc = google.classes.ExampleClass.docstring + doc = google.get("ExampleClass").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) assert x[0] == ("attr1", "str", "Description of `attr1`.") assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") - doc = google.classes.ExampleClass.functions["__init__"].docstring + doc = google.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) @@ -108,7 +108,7 @@ def test_iter_items_class(google): def test_get_return(google): - doc = google.functions.module_level_function.docstring + doc = google.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[2][1] x = parse_return(section, "google") @@ -118,12 +118,12 @@ def test_get_return(google): def test_parse_attribute(google): - doc = google.classes.ExampleClass.functions.readonly_property.docstring + doc = google.get("ExampleClass").get("readonly_property").docstring assert isinstance(doc, str) x = parse_attribute(doc) assert x[0] == "str" assert x[1] == "Properties should be documented in their getter method." - doc = google.classes.ExampleClass.functions.readwrite_property.docstring + doc = google.get("ExampleClass").get("readwrite_property").docstring assert isinstance(doc, str) x = parse_attribute(doc) assert x[0] == "list(str)" diff --git a/tests/docstring/test_numpy.py b/tests/docstring/test_numpy.py index e743c239..197e5981 100644 --- a/tests/docstring/test_numpy.py +++ b/tests/docstring/test_numpy.py @@ -54,7 +54,7 @@ def test_iter_sections(numpy: Module): def test_iter_items(numpy: Module): - doc = numpy.functions.module_level_function.docstring + doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[1][1] items = list(_iter_items(section)) @@ -72,7 +72,7 @@ def test_iter_items(numpy: Module): def test_split_item(numpy): - doc = numpy.functions.module_level_function.docstring + doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) sections = list(iter_sections(doc, "numpy")) items = list(_iter_items(sections[1][1])) @@ -90,13 +90,13 @@ def test_split_item(numpy): def test_iter_items_class(numpy): - doc = numpy.classes.ExampleClass.docstring + doc = numpy.get("ExampleClass").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) assert x[0] == ("attr1", "str", "Description of `attr1`.") assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") - doc = numpy.classes.ExampleClass.functions["__init__"].docstring + doc = numpy.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) @@ -105,7 +105,7 @@ def test_iter_items_class(numpy): def test_get_return(numpy): - doc = numpy.functions.module_level_function.docstring + doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[2][1] x = parse_return(section, "numpy") @@ -115,12 +115,12 @@ def test_get_return(numpy): def test_parse_attribute(numpy): - doc = numpy.classes.ExampleClass.functions.readonly_property.docstring + doc = numpy.get("ExampleClass").get("readonly_property").docstring assert isinstance(doc, str) x = parse_attribute(doc) assert x[0] == "str" assert x[1] == "Properties should be documented in their getter method." - doc = numpy.classes.ExampleClass.functions.readwrite_property.docstring + doc = numpy.get("ExampleClass").get("readwrite_property").docstring assert isinstance(doc, str) x = parse_attribute(doc) assert x[0] == "list(str)" From dc24d7ff822951055983b882e4260ad6ea09f92d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 3 Jan 2024 23:43:37 +0900 Subject: [PATCH 049/148] Docstring class --- src/mkapi/ast.py | 2 +- src/mkapi/base.py | 22 ------ src/mkapi/docstring.py | 137 ++++++++++++++++++++------------- tests/docstring/test_google.py | 14 +++- tests/docstring/test_numpy.py | 14 +++- 5 files changed, 106 insertions(+), 83 deletions(-) delete mode 100644 src/mkapi/base.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index f168a61b..415a1863 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -56,7 +56,7 @@ def get_module_node(name: str) -> ast.Module: @dataclass class Node: # noqa: D101 - _node: ast.AST + _node: AST name: str docstring: str | None diff --git a/src/mkapi/base.py b/src/mkapi/base.py deleted file mode 100644 index 238109e2..00000000 --- a/src/mkapi/base.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Base class.""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import ast - - -@dataclass -class Node: - """Node class.""" - - _node: ast.AST - name: str - """Name of item.""" - docstring: str | None - """Docstring of item.""" - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name!r})" diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index 70d3aacb..8e87f55d 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -12,6 +12,63 @@ type Style = Literal["google", "numpy"] + +@dataclass +class Item: # noqa: D101 + name: str + type: str # noqa: A003 + description: str + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name!r})" + + +SPLIT_ITEM_PATTERN = re.compile(r"\n\S") +SPLIT_NAME_TYPE_DESC_PATTERN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") + + +def _iter_items(section: str) -> Iterator[str]: + """Yield items for Parameters, Attributes, and Raises sections.""" + start = 0 + for m in SPLIT_ITEM_PATTERN.finditer(section): + yield section[start : m.start()].strip() + start = m.start() + yield section[start:].strip() + + +def _split_item_google(lines: list[str]) -> tuple[str, str, str]: + if m := re.match(SPLIT_NAME_TYPE_DESC_PATTERN, lines[0]): + name, type_, desc = m.groups() + elif ":" in lines[0]: + name, desc = lines[0].split(":", maxsplit=1) + type_ = "" + else: + name, type_, desc = lines[0], "", "" + return name, type_, "\n".join([desc.strip(), *lines[1:]]) + + +def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: + if ":" in lines[0]: + name, type_ = lines[0].split(":", maxsplit=1) + else: + name, type_ = lines[0], "" + return name.strip(), type_.strip(), "\n".join(lines[1:]) + + +def split_item(item: str, style: Style) -> tuple[str, str, str]: + """Return a tuple of (name, type, description).""" + lines = [line.strip() for line in item.split("\n")] + if style == "google": + return _split_item_google(lines) + return _split_item_numpy(lines) + + +def iter_items(section: str, style: Style) -> Iterator[Item]: + """Yiled a tuple of (name, type, description) of item.""" + for item in _iter_items(section): + yield Item(*split_item(item, style)) + + SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { "google": re.compile(r"\n\n\S"), "numpy": re.compile(r"\n\n\n|\n\n.+?\n-+\n"), @@ -95,54 +152,6 @@ def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_desc -SPLIT_ITEM_PATTERN = re.compile(r"\n\S") - - -def _iter_items(section: str) -> Iterator[str]: - """Yield items for Parameters, Attributes, and Raises sections.""" - start = 0 - for m in SPLIT_ITEM_PATTERN.finditer(section): - yield section[start : m.start()].strip() - start = m.start() - yield section[start:].strip() - - -SPLIT_NAME_TYPE_DESC_PATTEN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") - - -def _split_item_google(lines: list[str]) -> tuple[str, str, str]: - if m := re.match(SPLIT_NAME_TYPE_DESC_PATTEN, lines[0]): - name, type_, desc = m.groups() - elif ":" in lines[0]: - name, desc = lines[0].split(":", maxsplit=1) - type_ = "" - else: - name, type_, desc = lines[0], "", "" - return name, type_, "\n".join([desc.strip(), *lines[1:]]) - - -def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: - if ":" in lines[0]: - name, type_ = lines[0].split(":", maxsplit=1) - else: - name, type_ = lines[0], "" - return name.strip(), type_.strip(), "\n".join(lines[1:]) - - -def split_item(item: str, style: Style) -> tuple[str, str, str]: - """Return a tuple of (name, type, description).""" - lines = [line.strip() for line in item.split("\n")] - if style == "google": - return _split_item_google(lines) - return _split_item_numpy(lines) - - -def iter_items(section: str, style: Style) -> Iterator[tuple[str, str, str]]: - """Yiled a tuple of (name, type, description) of item.""" - for item in _iter_items(section): - yield split_item(item, style) - - def parse_return(section: str, style: Style) -> tuple[str, str]: """Return a tuple of (type, description) for Returns and Yields section.""" lines = section.split("\n") @@ -154,11 +163,39 @@ def parse_return(section: str, style: Style) -> tuple[str, str]: return "", section +# for mkapi.ast.Attribute.docstring def parse_attribute(docstring: str) -> tuple[str, str]: """Return a tuple of (type, description) for Attribute docstring.""" return parse_return(docstring, "google") +@dataclass +class Section(Item): # noqa: D101 + items: list[Item] + + +@dataclass +class Docstring(Item): # noqa: D101 + sections: list[Section] + + +def get_docstring(doc: str, style: Style) -> Docstring: + """Return a docstring instance.""" + doc = add_fence(doc) + sections: list[Section] = [] + for name, desc in iter_sections(doc, style): + type_ = desc_ = "" + items: list[Item] = [] + if name in ["Parameters", "Attributes", "Raises"]: + items = list(iter_items(desc, style)) + elif name in ["Returns", "Yields"]: + type_, desc_ = parse_return(desc, style) + elif name in ["Note", "Notes", "Warning", "Warnings"]: + desc_ = add_admonition(name, desc) + sections.append(Section(name, type_, desc_, items)) + return Docstring("", "", "", sections) + + # @dataclass # class Base: # """Base class.""" @@ -221,7 +258,3 @@ def parse_attribute(docstring: str) -> tuple[str, str]: # continue # section.name = "" # section.markdown = markdown -def parse_docstring(doc: str): - doc = add_fence(doc) - # if section.name in ["Note", "Notes", "Warning", "Warnings"]: - # secton = add_admonition(section.name, section.markdown) diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py index 854033ac..b69539cb 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstring/test_google.py @@ -97,14 +97,20 @@ def test_iter_items_class(google): assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) - assert x[0] == ("attr1", "str", "Description of `attr1`.") - assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") + assert x[0].name == "attr1" + assert x[0].type == "str" + assert x[0].description == "Description of `attr1`." + assert x[1].name == "attr2" + assert x[1].type == ":obj:`int`, optional" + assert x[1].description == "Description of `attr2`." doc = google.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) - assert x[0] == ("param1", "str", "Description of `param1`.") - assert x[1][2] == "Description of `param2`. Multiple\nlines are supported." + assert x[0].name == "param1" + assert x[0].type == "str" + assert x[0].description == "Description of `param1`." + assert x[1].description == "Description of `param2`. Multiple\nlines are supported." def test_get_return(google): diff --git a/tests/docstring/test_numpy.py b/tests/docstring/test_numpy.py index 197e5981..e8f76760 100644 --- a/tests/docstring/test_numpy.py +++ b/tests/docstring/test_numpy.py @@ -94,14 +94,20 @@ def test_iter_items_class(numpy): assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) - assert x[0] == ("attr1", "str", "Description of `attr1`.") - assert x[1] == ("attr2", ":obj:`int`, optional", "Description of `attr2`.") + assert x[0].name == "attr1" + assert x[0].type == "str" + assert x[0].description == "Description of `attr1`." + assert x[1].name == "attr2" + assert x[1].type == ":obj:`int`, optional" + assert x[1].description == "Description of `attr2`." doc = numpy.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) - assert x[0] == ("param1", "str", "Description of `param1`.") - assert x[1][2] == "Description of `param2`. Multiple\nlines are supported." + assert x[0].name == "param1" + assert x[0].type == "str" + assert x[0].description == "Description of `param1`." + assert x[1].description == "Description of `param2`. Multiple\nlines are supported." def test_get_return(numpy): From 9e3a60d87775fcdfbb7932610a4ee9d4e4ad8cf6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 07:10:21 +0900 Subject: [PATCH 050/148] Get module from name --- examples/filter.py | 15 ----- examples/typing/__init__.py | 0 examples/typing/current.py | 2 - examples/typing/future.py | 5 -- src/mkapi/ast.py | 102 +++++++++++++++++++++------------ src/mkapi/docstring.py | 6 +- src/mkapi/modules.py | 74 ------------------------ tests/ast/test_defs.py | 4 +- tests/ast/test_eval.py | 4 +- tests/ast/test_node.py | 14 +++++ tests/conftest.py | 8 +-- tests/docstring/test_google.py | 10 ++-- tests/docstring/test_numpy.py | 10 ++-- 13 files changed, 99 insertions(+), 155 deletions(-) delete mode 100644 examples/filter.py delete mode 100644 examples/typing/__init__.py delete mode 100644 examples/typing/current.py delete mode 100644 examples/typing/future.py diff --git a/examples/filter.py b/examples/filter.py deleted file mode 100644 index dee0dbf2..00000000 --- a/examples/filter.py +++ /dev/null @@ -1,15 +0,0 @@ -from collections.abc import Iterator - - -def func(x: int): - """Function.""" - return x - - -def gen() -> Iterator[int]: - """Generator.""" - yield 1 - - -class C: - """Class.""" diff --git a/examples/typing/__init__.py b/examples/typing/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/typing/current.py b/examples/typing/current.py deleted file mode 100644 index 8dba3eec..00000000 --- a/examples/typing/current.py +++ /dev/null @@ -1,2 +0,0 @@ -def func(x: int) -> str: - return str(x) diff --git a/examples/typing/future.py b/examples/typing/future.py deleted file mode 100644 index d34e22ea..00000000 --- a/examples/typing/future.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - - -def func(x: int) -> str: - return str(x) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 415a1863..6267e6b4 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -13,7 +13,6 @@ ImportFrom, Name, NodeTransformer, - Raise, TypeAlias, ) from dataclasses import dataclass @@ -33,26 +32,6 @@ type Def = FunctionDef_ | ClassDef type Assign_ = Assign | AnnAssign | TypeAlias -module_cache: dict[str, tuple[float, ast.Module]] = {} - - -def get_module_node(name: str) -> ast.Module: - """Return a [ast.Module] node by name.""" - spec = find_spec(name) - if not spec or not spec.origin: - raise ModuleNotFoundError - path = Path(spec.origin) - mtime = path.stat().st_mtime - if name in module_cache and mtime == module_cache[name][0]: - return module_cache[name][1] - if not path.exists(): - raise ModuleNotFoundError - with path.open(encoding="utf-8") as f: - source = f.read() - node = ast.parse(source) - module_cache[name] = (mtime, node) - return node - @dataclass class Node: # noqa: D101 @@ -194,24 +173,49 @@ def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) +@dataclass(repr=False) +class Raise(Node): # noqa: D101 + _node: ast.Raise + type: ast.expr | None # # noqa: A003 + + +def iter_raises(node: FunctionDef_) -> Iterator[Raise]: + """Yield raise nodes.""" + for child in ast.walk(node): + if isinstance(child, ast.Raise) and child.exc: + yield Raise(child, "", None, child.exc) + + +@dataclass(repr=False) +class Return(Node): # noqa: D101 + _node: ast.expr | None + type: ast.expr | None # # noqa: A003 + + +def get_return(node: FunctionDef_) -> Return: + """Yield raise nodes.""" + ret = node.returns + return Return(ret, "", None, ret) + + @dataclass(repr=False) class Definition(Node): # noqa: D101 parameters: list[Parameter] decorators: list[ast.expr] type_params: list[ast.type_param] - raises: list[ast.expr] + raises: list[Raise] @dataclass(repr=False) class Function(Definition): # noqa: D101 _node: FunctionDef_ - returns: ast.expr | None + returns: Return @dataclass(repr=False) class Class(Definition): # noqa: D101 _node: ClassDef - bases: list[ast.expr] + bases: list[Class] attributes: list[Attribute] classes: list[Class] functions: list[Function] @@ -233,13 +237,6 @@ def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: yield child -def iter_raise_nodes(node: FunctionDef_) -> Iterator[Raise]: - """Yield raise nodes.""" - for child in ast.walk(node): - if isinstance(child, Raise): - yield child - - def _get_def_args( node: Def, ) -> tuple[str, str | None, list[Parameter], list[ast.expr], list[ast.type_param]]: @@ -258,10 +255,11 @@ def iter_definitions(node: ast.Module | ClassDef) -> Iterator[Class | Function]: if isinstance(def_node, ClassDef): attrs = list(iter_attributes(def_node)) classes, functions = get_definitions(def_node) - yield Class(def_node, *args, [], def_node.bases, attrs, classes, functions) + bases: list[Class] = [] # TODO: use def_node.bases + yield Class(def_node, *args, [], bases, attrs, classes, functions) else: - raises = [r.exc for r in iter_raise_nodes(def_node) if r.exc] - yield Function(def_node, *args, raises, def_node.returns) + raises = list(iter_raises(def_node)) + yield Function(def_node, *args, raises, get_return(def_node)) def get_definitions(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: @@ -293,8 +291,22 @@ def get(self, name: str) -> Class | Function: # noqa: D102 raise NameError -def get_module(node: ast.Module) -> Module: - """Return a [Module] instance.""" +cache_module_node: dict[str, tuple[float, ast.Module | None]] = {} +cache_module: dict[str, Module | None] = {} + + +def get_module(name: str) -> Module | None: + """Return a [Module] instance by name.""" + if name in cache_module: + return cache_module[name] + node = get_module_node(name) + module = node and get_module_from_node(node) + cache_module[name] = module + return module + + +def get_module_from_node(node: ast.Module) -> Module: + """Return a [Module] instance from [ast.Module] node.""" docstring = ast.get_docstring(node) imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) @@ -302,6 +314,24 @@ def get_module(node: ast.Module) -> Module: return Module(node, "", docstring, imports, attrs, classes, functions) +def get_module_node(name: str) -> ast.Module | None: + """Return a [ast.Module] node by name.""" + spec = find_spec(name) + if not spec or not spec.origin: + return None + path = Path(spec.origin) + mtime = path.stat().st_mtime + if name in cache_module_node and mtime == cache_module_node[name][0]: + return cache_module_node[name][1] + with path.open(encoding="utf-8") as f: + source = f.read() + node = ast.parse(source) + cache_module_node[name] = (mtime, node) + if name in cache_module: + del cache_module[name] + return node + + class Transformer(NodeTransformer): # noqa: D101 def _rename(self, name: str) -> Name: return Name(id=f"__mkapi__.{name}") diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index 8e87f55d..bc19eda3 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -152,7 +152,7 @@ def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_desc -def parse_return(section: str, style: Style) -> tuple[str, str]: +def split_return(section: str, style: Style) -> tuple[str, str]: """Return a tuple of (type, description) for Returns and Yields section.""" lines = section.split("\n") if style == "google" and ":" in lines[0]: @@ -164,9 +164,9 @@ def parse_return(section: str, style: Style) -> tuple[str, str]: # for mkapi.ast.Attribute.docstring -def parse_attribute(docstring: str) -> tuple[str, str]: +def split_attribute(docstring: str) -> tuple[str, str]: """Return a tuple of (type, description) for Attribute docstring.""" - return parse_return(docstring, "google") + return split_return(docstring, "google") @dataclass diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py index 901a7d41..e69de29b 100644 --- a/src/mkapi/modules.py +++ b/src/mkapi/modules.py @@ -1,74 +0,0 @@ -# """Modules.""" -# from __future__ import annotations - -# import ast -# import re -# from dataclasses import dataclass -# from importlib.util import find_spec -# from pathlib import Path -# from typing import TYPE_CHECKING, TypeAlias - -# import mkapi.ast.node -# from mkapi import config - -# if TYPE_CHECKING: -# from collections.abc import Iterator - -# Def: TypeAlias = ast.AsyncFunctionDef | ast.FunctionDef | ast.ClassDef -# Assign_: TypeAlias = ast.Assign | ast.AnnAssign -# Node: TypeAlias = Def | Assign_ - - -# @dataclass -# class Module: -# """Module class.""" - -# name: str -# path: Path -# source: str -# mtime: float -# node: ast.Module - -# def __repr__(self) -> str: -# class_name = self.__class__.__name__ -# return f"{class_name}({self.name!r})" - -# def is_package(self) -> bool: -# """Return True if the module is a package.""" -# return self.path.stem == "__init__" - -# def update(self) -> None: -# """Update contents.""" - -# def get_node(self, name: str) -> Node: -# """Return a node by name.""" -# nodes = mkapi.ast.node.get_nodes(self.node) -# node = mkapi.ast.node.get_by_name(nodes, name) -# if node is None: -# raise NameError -# return node - -# def get_names(self) -> dict[str, str]: -# """Return a dictionary of names as (name => fullname).""" -# return dict(mkapi.ast.node.iter_names(self.node)) - -# def iter_submodules(self) -> Iterator[Module]: -# """Yield submodules.""" -# if self.is_package(): -# for module in iter_submodules(self): -# yield module -# yield from module.iter_submodules() - -# def get_tree(self) -> tuple[Module, list]: -# """Return the package tree structure.""" -# modules: list[Module | tuple[Module, list]] = [] -# for module in find_submodules(self): -# if module.is_package(): -# modules.append(module.get_tree()) -# else: -# modules.append(module) -# return (self, modules) - -# def get_markdown(self, filters: list[str] | None) -> str: -# """Return the markdown text of the module.""" -# return f"# {self.name}\n\n## {self.name}\n" diff --git a/tests/ast/test_defs.py b/tests/ast/test_defs.py index 11832ec2..0a1a2ca8 100644 --- a/tests/ast/test_defs.py +++ b/tests/ast/test_defs.py @@ -1,10 +1,10 @@ import ast -from mkapi.ast import get_module +from mkapi.ast import get_module_from_node def _get(src: str): - return get_module(ast.parse(src)) + return get_module_from_node(ast.parse(src)) def test_deco(): diff --git a/tests/ast/test_eval.py b/tests/ast/test_eval.py index 73231c7e..48a7a68d 100644 --- a/tests/ast/test_eval.py +++ b/tests/ast/test_eval.py @@ -6,7 +6,6 @@ Module, StringTransformer, get_module, - get_module_node, iter_identifiers, ) @@ -38,8 +37,7 @@ def test_parse_expr_str(): @pytest.fixture(scope="module") def module(): - node = get_module_node("mkapi.ast") - return get_module(node) + return get_module("mkapi.ast") def test_iter_identifiers(): diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index d583217a..3c5f2d83 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -3,6 +3,7 @@ import pytest +import mkapi.ast from mkapi.ast import ( get_module, get_module_node, @@ -21,6 +22,9 @@ def test_module_cache(): node1 = get_module_node("mkdocs") node2 = get_module_node("mkdocs") assert node1 is node2 + module1 = get_module("mkapi") + module2 = get_module("mkapi") + assert module1 is module2 @pytest.fixture(scope="module") @@ -57,3 +61,13 @@ def def_nodes(module: Module): def test_iter_definition_nodes(def_nodes): assert any(node.name == "get_files" for node in def_nodes) assert any(node.name == "Files" for node in def_nodes) + + +def test_not_found(): + assert get_module_node("xxx") is None + assert get_module("xxx") is None + assert mkapi.ast.cache_module["xxx"] is None + assert "xxx" not in mkapi.ast.cache_module_node + assert get_module("markdown") is not None + assert "markdown" in mkapi.ast.cache_module + assert "markdown" in mkapi.ast.cache_module_node diff --git a/tests/conftest.py b/tests/conftest.py index d965ab8e..b90cfd99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from mkapi.ast import get_module, get_module_node +from mkapi.ast import get_module @pytest.fixture(scope="module") @@ -11,8 +11,7 @@ def google(): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) - node = get_module_node("examples.styles.example_google") - return get_module(node) + return get_module("examples.styles.example_google") @pytest.fixture(scope="module") @@ -20,5 +19,4 @@ def numpy(): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) - node = get_module_node("examples.styles.example_numpy") - return get_module(node) + return get_module("examples.styles.example_numpy") diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py index b69539cb..1a86e695 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstring/test_google.py @@ -3,9 +3,9 @@ _iter_items, iter_items, iter_sections, - parse_attribute, - parse_return, + split_attribute, split_item, + split_return, split_section, ) @@ -117,7 +117,7 @@ def test_get_return(google): doc = google.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[2][1] - x = parse_return(section, "google") + x = split_return(section, "google") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") @@ -126,12 +126,12 @@ def test_get_return(google): def test_parse_attribute(google): doc = google.get("ExampleClass").get("readonly_property").docstring assert isinstance(doc, str) - x = parse_attribute(doc) + x = split_attribute(doc) assert x[0] == "str" assert x[1] == "Properties should be documented in their getter method." doc = google.get("ExampleClass").get("readwrite_property").docstring assert isinstance(doc, str) - x = parse_attribute(doc) + x = split_attribute(doc) assert x[0] == "list(str)" assert x[1].startswith("Properties with both") assert x[1].endswith("mentioned here.") diff --git a/tests/docstring/test_numpy.py b/tests/docstring/test_numpy.py index e8f76760..7b5139be 100644 --- a/tests/docstring/test_numpy.py +++ b/tests/docstring/test_numpy.py @@ -3,9 +3,9 @@ _iter_items, iter_items, iter_sections, - parse_attribute, - parse_return, + split_attribute, split_item, + split_return, split_section, ) @@ -114,7 +114,7 @@ def test_get_return(numpy): doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "numpy"))[2][1] - x = parse_return(section, "numpy") + x = split_return(section, "numpy") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") @@ -123,12 +123,12 @@ def test_get_return(numpy): def test_parse_attribute(numpy): doc = numpy.get("ExampleClass").get("readonly_property").docstring assert isinstance(doc, str) - x = parse_attribute(doc) + x = split_attribute(doc) assert x[0] == "str" assert x[1] == "Properties should be documented in their getter method." doc = numpy.get("ExampleClass").get("readwrite_property").docstring assert isinstance(doc, str) - x = parse_attribute(doc) + x = split_attribute(doc) assert x[0] == "list(str)" assert x[1].startswith("Properties with both") assert x[1].endswith("mentioned here.") From 651314a7662826835ef33136cbeb9dfa15bd2107 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 07:29:56 +0900 Subject: [PATCH 051/148] Store source --- src/mkapi/ast.py | 24 +++++++++++++++++------- tests/ast/test_node.py | 6 ++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 6267e6b4..e261418e 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -280,6 +280,7 @@ class Module(Node): # noqa: D101 attributes: list[Attribute] classes: list[Class] functions: list[Function] + source: str def get(self, name: str) -> Class | Function: # noqa: D102 names = [cls.name for cls in self.classes] @@ -291,7 +292,7 @@ def get(self, name: str) -> Class | Function: # noqa: D102 raise NameError -cache_module_node: dict[str, tuple[float, ast.Module | None]] = {} +cache_module_node: dict[str, tuple[float, ast.Module | None, str]] = {} cache_module: dict[str, Module | None] = {} @@ -299,10 +300,13 @@ def get_module(name: str) -> Module | None: """Return a [Module] instance by name.""" if name in cache_module: return cache_module[name] - node = get_module_node(name) - module = node and get_module_from_node(node) - cache_module[name] = module - return module + if node := get_module_node(name): + module = get_module_from_node(node) + module.source = _get_source(name) + cache_module[name] = module + return module + cache_module[name] = None + return None def get_module_from_node(node: ast.Module) -> Module: @@ -311,7 +315,7 @@ def get_module_from_node(node: ast.Module) -> Module: imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_definitions(node) - return Module(node, "", docstring, imports, attrs, classes, functions) + return Module(node, "", docstring, imports, attrs, classes, functions, "") def get_module_node(name: str) -> ast.Module | None: @@ -326,12 +330,18 @@ def get_module_node(name: str) -> ast.Module | None: with path.open(encoding="utf-8") as f: source = f.read() node = ast.parse(source) - cache_module_node[name] = (mtime, node) + cache_module_node[name] = (mtime, node, source) if name in cache_module: del cache_module[name] return node +def _get_source(name: str) -> str: + if name in cache_module_node: + return cache_module_node[name][2] + return "" + + class Transformer(NodeTransformer): # noqa: D101 def _rename(self, name: str) -> Name: return Name(id=f"__mkapi__.{name}") diff --git a/tests/ast/test_node.py b/tests/ast/test_node.py index 3c5f2d83..e0b8dd65 100644 --- a/tests/ast/test_node.py +++ b/tests/ast/test_node.py @@ -71,3 +71,9 @@ def test_not_found(): assert get_module("markdown") is not None assert "markdown" in mkapi.ast.cache_module assert "markdown" in mkapi.ast.cache_module_node + + +def test_source(): + module = get_module("mkdocs.structure.files") + assert module + assert "class File" in module.source From b02d80a3a353e06f236097cdd49d295b2e50b813 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 10:42:49 +0900 Subject: [PATCH 052/148] ast -> objects --- src/mkapi/inspect.py | 24 ++++ src/mkapi/modules.py | 0 src/mkapi/{ast.py => objects.py} | 112 +++++++++++++----- tests/conftest.py | 2 +- tests/docstring/test_google.py | 2 +- tests/docstring/test_numpy.py | 2 +- tests/{ast => objects}/test_args.py | 2 +- tests/{ast => objects}/test_attrs.py | 2 +- tests/{ast => objects}/test_defs.py | 6 +- tests/{ast => objects}/test_eval.py | 32 ++--- .../test_node.py => objects/test_object.py} | 24 ++-- tests/test_inspect.py | 40 +++++++ tests/test_modules.py | 0 13 files changed, 187 insertions(+), 61 deletions(-) create mode 100644 src/mkapi/inspect.py delete mode 100644 src/mkapi/modules.py rename src/mkapi/{ast.py => objects.py} (74%) rename tests/{ast => objects}/test_args.py (94%) rename tests/{ast => objects}/test_attrs.py (91%) rename tests/{ast => objects}/test_defs.py (54%) rename tests/{ast => objects}/test_eval.py (66%) rename tests/{ast/test_node.py => objects/test_object.py} (71%) create mode 100644 tests/test_inspect.py delete mode 100644 tests/test_modules.py diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py new file mode 100644 index 00000000..2396648b --- /dev/null +++ b/src/mkapi/inspect.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeGuard + +from mkapi.objects import Class, Function, Import, Module, get_module + + +@dataclass(repr=False) +class Object: + """Object class.""" + + _obj: Module | Class | Function | Import + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._obj})" + + def ismodule(self) -> bool: + return isinstance(self._obj, Module) + + +def get_object(name: str) -> Object: + if module := get_module(name): + x = Object(module) diff --git a/src/mkapi/modules.py b/src/mkapi/modules.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/mkapi/ast.py b/src/mkapi/objects.py similarity index 74% rename from src/mkapi/ast.py rename to src/mkapi/objects.py index e261418e..e21f747e 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/objects.py @@ -15,7 +15,7 @@ NodeTransformer, TypeAlias, ) -from dataclasses import dataclass +from dataclasses import dataclass, field from importlib.util import find_spec from inspect import Parameter as P # noqa: N817 from inspect import cleandoc @@ -32,21 +32,51 @@ type Def = FunctionDef_ | ClassDef type Assign_ = Assign | AnnAssign | TypeAlias +current_module_name: list[str | None] = [None] + @dataclass -class Node: # noqa: D101 +class Object: # noqa: D101 _node: AST name: str docstring: str | None + def __post_init__(self) -> None: + self.__dict__["__module_name__"] = current_module_name[0] + def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name!r})" + return f"{self.__class__.__name__}({self.name})" + def unparse(self) -> str: # noqa: D102 + return ast.unparse(self._node) -@dataclass(repr=False) -class Import(Node): # noqa: D101 - _node: ast.Import | ImportFrom + def get_module_name(self) -> str | None: + """Return the module name if exist.""" + return self.__dict__["__module_name__"] + + def get_module(self) -> Module | None: + """Return a [Module] instance if exist.""" + if module_name := self.get_module_name(): + return get_module(module_name) + return None + + def get_source(self) -> str: + """Return the source code.""" + if (name := self.get_module_name()) and (source := _get_source(name)): + lines = source.split("\n") + return "\n".join(lines[self._node.lineno - 1 : self._node.end_lineno]) + return "" + + +@dataclass +class Import(Object): # noqa: D101 + _node: ast.Import | ImportFrom = field(repr=False) + docstring: str | None = field(repr=False) fullname: str + from_: str | None + + # def __repr__(self) -> str: + # return f"{self.__class__.__name__}({self.name}: {self.fullname})" def iter_import_nodes(node: AST) -> Iterator[Import_]: @@ -61,15 +91,15 @@ def iter_import_nodes(node: AST) -> Iterator[Import_]: def iter_imports(node: ast.Module) -> Iterator[Import]: """Yield import nodes and names.""" for child in iter_import_nodes(node): - from_module = f"{child.module}." if isinstance(child, ImportFrom) else "" + from_ = f"{child.module}" if isinstance(child, ImportFrom) else None for alias in child.names: name = alias.asname or alias.name - fullname = f"{from_module}{alias.name}" - yield Import(child, name, None, fullname) + fullname = f"{from_}.{alias.name}" if from_ else name + yield Import(child, name, None, fullname, from_) @dataclass(repr=False) -class Attribute(Node): # noqa: D101 +class Attribute(Object): # noqa: D101 _node: Assign_ type: ast.expr | None # # noqa: A003 default: ast.expr | None @@ -134,7 +164,7 @@ def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: @dataclass(repr=False) -class Parameter(Node): # noqa: D101 +class Parameter(Object): # noqa: D101 _node: ast.arg type: ast.expr | None # # noqa: A003 default: ast.expr | None @@ -174,7 +204,7 @@ def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: @dataclass(repr=False) -class Raise(Node): # noqa: D101 +class Raise(Object): # noqa: D101 _node: ast.Raise type: ast.expr | None # # noqa: A003 @@ -187,7 +217,7 @@ def iter_raises(node: FunctionDef_) -> Iterator[Raise]: @dataclass(repr=False) -class Return(Node): # noqa: D101 +class Return(Object): # noqa: D101 _node: ast.expr | None type: ast.expr | None # # noqa: A003 @@ -199,7 +229,7 @@ def get_return(node: FunctionDef_) -> Return: @dataclass(repr=False) -class Definition(Node): # noqa: D101 +class Callable(Object): # noqa: D101 parameters: list[Parameter] decorators: list[ast.expr] type_params: list[ast.type_param] @@ -207,13 +237,13 @@ class Definition(Node): # noqa: D101 @dataclass(repr=False) -class Function(Definition): # noqa: D101 +class Function(Callable): # noqa: D101 _node: FunctionDef_ returns: Return @dataclass(repr=False) -class Class(Definition): # noqa: D101 +class Class(Callable): # noqa: D101 _node: ClassDef bases: list[Class] attributes: list[Attribute] @@ -230,14 +260,14 @@ def get(self, name: str) -> Class | Function: # noqa: D102 raise NameError -def iter_definition_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: +def iter_callable_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: """Yield definition nodes.""" for child in ast.iter_child_nodes(node): if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield child -def _get_def_args( +def _get_callable_args( node: Def, ) -> tuple[str, str | None, list[Parameter], list[ast.expr], list[ast.type_param]]: name = node.name @@ -248,13 +278,13 @@ def _get_def_args( return name, docstring, parameters, decorators, type_params -def iter_definitions(node: ast.Module | ClassDef) -> Iterator[Class | Function]: +def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" - for def_node in iter_definition_nodes(node): - args = _get_def_args(def_node) + for def_node in iter_callable_nodes(node): + args = _get_callable_args(def_node) if isinstance(def_node, ClassDef): attrs = list(iter_attributes(def_node)) - classes, functions = get_definitions(def_node) + classes, functions = get_callables(def_node) bases: list[Class] = [] # TODO: use def_node.bases yield Class(def_node, *args, [], bases, attrs, classes, functions) else: @@ -262,11 +292,11 @@ def iter_definitions(node: ast.Module | ClassDef) -> Iterator[Class | Function]: yield Function(def_node, *args, raises, get_return(def_node)) -def get_definitions(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: +def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: """Return a tuple of (list[Class], list[Function]).""" classes: list[Class] = [] functions: list[Function] = [] - for definition in iter_definitions(node): + for definition in iter_callables(node): if isinstance(definition, Class): classes.append(definition) else: @@ -274,23 +304,30 @@ def get_definitions(node: ast.Module | ClassDef) -> tuple[list[Class], list[Func return classes, functions -@dataclass -class Module(Node): # noqa: D101 +@dataclass(repr=False) +class Module(Object): # noqa: D101 imports: list[Import] attributes: list[Attribute] classes: list[Class] functions: list[Function] source: str - def get(self, name: str) -> Class | Function: # noqa: D102 + def get(self, name: str) -> Class | Function | Import: # noqa: D102 names = [cls.name for cls in self.classes] if name in names: return self.classes[names.index(name)] names = [func.name for func in self.functions] if name in names: return self.functions[names.index(name)] + names = [imp.name for imp in self.imports] + if name in names: + return self.imports[names.index(name)] raise NameError + def get_source(self) -> str: + """Return the source code.""" + return _get_source(self.name) if self.name else "" + cache_module_node: dict[str, tuple[float, ast.Module | None, str]] = {} cache_module: dict[str, Module | None] = {} @@ -301,7 +338,10 @@ def get_module(name: str) -> Module | None: if name in cache_module: return cache_module[name] if node := get_module_node(name): + current_module_name[0] = name module = get_module_from_node(node) + current_module_name[0] = None + module.name = name module.source = _get_source(name) cache_module[name] = module return module @@ -314,13 +354,16 @@ def get_module_from_node(node: ast.Module) -> Module: docstring = ast.get_docstring(node) imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) - classes, functions = get_definitions(node) + classes, functions = get_callables(node) return Module(node, "", docstring, imports, attrs, classes, functions, "") def get_module_node(name: str) -> ast.Module | None: """Return a [ast.Module] node by name.""" - spec = find_spec(name) + try: + spec = find_spec(name) + except ModuleNotFoundError: + return None if not spec or not spec.origin: return None path = Path(spec.origin) @@ -342,6 +385,15 @@ def _get_source(name: str) -> str: return "" +def get_module_from_import(import_: Import) -> Module | None: + """Return a [Module] instance from [Import].""" + if module := get_module(import_.fullname): + return module + if import_.from_ and (module := get_module(import_.from_)): + return module + return None + + class Transformer(NodeTransformer): # noqa: D101 def _rename(self, name: str) -> Name: return Name(id=f"__mkapi__.{name}") @@ -360,7 +412,7 @@ def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 return node -def iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: +def _iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: """Yield identifiers as a tuple of (code, isidentifier).""" start = 0 while start < len(source): diff --git a/tests/conftest.py b/tests/conftest.py index b90cfd99..55dd0fe9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from mkapi.ast import get_module +from mkapi.objects import get_module @pytest.fixture(scope="module") diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py index 1a86e695..0ab4ecf0 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstring/test_google.py @@ -1,4 +1,3 @@ -from mkapi.ast import Module from mkapi.docstring import ( _iter_items, iter_items, @@ -8,6 +7,7 @@ split_return, split_section, ) +from mkapi.objects import Module def test_split_section(): diff --git a/tests/docstring/test_numpy.py b/tests/docstring/test_numpy.py index 7b5139be..622e3e04 100644 --- a/tests/docstring/test_numpy.py +++ b/tests/docstring/test_numpy.py @@ -1,4 +1,3 @@ -from mkapi.ast import Module from mkapi.docstring import ( _iter_items, iter_items, @@ -8,6 +7,7 @@ split_return, split_section, ) +from mkapi.objects import Module def test_split_section(): diff --git a/tests/ast/test_args.py b/tests/objects/test_args.py similarity index 94% rename from tests/ast/test_args.py rename to tests/objects/test_args.py index 3a0116b5..a81bfbb4 100644 --- a/tests/ast/test_args.py +++ b/tests/objects/test_args.py @@ -1,7 +1,7 @@ import ast from inspect import Parameter -from mkapi.ast import iter_parameters +from mkapi.objects import iter_parameters def _get_args(source: str): diff --git a/tests/ast/test_attrs.py b/tests/objects/test_attrs.py similarity index 91% rename from tests/ast/test_attrs.py rename to tests/objects/test_attrs.py index ed4ea316..61e738c8 100644 --- a/tests/ast/test_attrs.py +++ b/tests/objects/test_attrs.py @@ -1,6 +1,6 @@ import ast -from mkapi.ast import iter_attributes +from mkapi.objects import iter_attributes def _get_attributes(source: str): diff --git a/tests/ast/test_defs.py b/tests/objects/test_defs.py similarity index 54% rename from tests/ast/test_defs.py rename to tests/objects/test_defs.py index 0a1a2ca8..8eb622f4 100644 --- a/tests/ast/test_defs.py +++ b/tests/objects/test_defs.py @@ -1,6 +1,6 @@ import ast -from mkapi.ast import get_module_from_node +from mkapi.objects import Class, get_module_from_node def _get(src: str): @@ -9,5 +9,7 @@ def _get(src: str): def test_deco(): module = _get("@f(x,a=1)\nclass A:\n pass") - deco = module.get("A").decorators[0] + cls = module.get("A") + assert isinstance(cls, Class) + deco = cls.decorators[0] assert isinstance(deco, ast.Call) diff --git a/tests/ast/test_eval.py b/tests/objects/test_eval.py similarity index 66% rename from tests/ast/test_eval.py rename to tests/objects/test_eval.py index 48a7a68d..661a21c8 100644 --- a/tests/ast/test_eval.py +++ b/tests/objects/test_eval.py @@ -2,11 +2,12 @@ import pytest -from mkapi.ast import ( +from mkapi.objects import ( + Function, Module, StringTransformer, + _iter_identifiers, get_module, - iter_identifiers, ) @@ -37,39 +38,40 @@ def test_parse_expr_str(): @pytest.fixture(scope="module") def module(): - return get_module("mkapi.ast") + return get_module("mkapi.objects") def test_iter_identifiers(): - x = list(iter_identifiers("x, __mkapi__.a.b0[__mkapi__.c], y")) + x = list(_iter_identifiers("x, __mkapi__.a.b0[__mkapi__.c], y")) assert len(x) == 5 assert x[0] == ("x, ", False) assert x[1] == ("a.b0", True) assert x[2] == ("[", False) assert x[3] == ("c", True) assert x[4] == ("], y", False) - x = list(iter_identifiers("__mkapi__.a.b()")) + x = list(_iter_identifiers("__mkapi__.a.b()")) assert len(x) == 2 assert x[0] == ("a.b", True) assert x[1] == ("()", False) - x = list(iter_identifiers("'ab'\n __mkapi__.a")) + x = list(_iter_identifiers("'ab'\n __mkapi__.a")) assert len(x) == 2 assert x[0] == ("'ab'\n ", False) assert x[1] == ("a", True) - x = list(iter_identifiers("'ab'\n __mkapi__.α.β.γ")) # noqa: RUF001 + x = list(_iter_identifiers("'ab'\n __mkapi__.α.β.γ")) # noqa: RUF001 assert len(x) == 2 assert x[0] == ("'ab'\n ", False) assert x[1] == ("α.β.γ", True) # noqa: RUF001 def test_functions(module: Module): - func = module.get("_get_def_args") + func = module.get("_get_callable_args") + assert isinstance(func, Function) type_ = func.parameters[0].type assert isinstance(type_, ast.expr) - text = StringTransformer().unparse(type_) - for s in iter_identifiers(text): - print(s) - print(module.imports) - print(module.classes) - print(module.attributes) - print(module.functions) + # text = StringTransformer().unparse(type_) + # for s in _iter_identifiers(text): + # print(s) + # print(module.imports) + # print(module.classes) + # print(module.attributes) + # print(module.functions) diff --git a/tests/ast/test_node.py b/tests/objects/test_object.py similarity index 71% rename from tests/ast/test_node.py rename to tests/objects/test_object.py index e0b8dd65..3b73ec10 100644 --- a/tests/ast/test_node.py +++ b/tests/objects/test_object.py @@ -3,11 +3,11 @@ import pytest -import mkapi.ast -from mkapi.ast import ( +import mkapi.objects +from mkapi.objects import ( get_module, get_module_node, - iter_definition_nodes, + iter_callable_nodes, iter_import_nodes, iter_imports, ) @@ -55,7 +55,7 @@ def test_get_import_names(module: Module): @pytest.fixture(scope="module") def def_nodes(module: Module): - return list(iter_definition_nodes(module)) + return list(iter_callable_nodes(module)) def test_iter_definition_nodes(def_nodes): @@ -66,14 +66,20 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): assert get_module_node("xxx") is None assert get_module("xxx") is None - assert mkapi.ast.cache_module["xxx"] is None - assert "xxx" not in mkapi.ast.cache_module_node + assert mkapi.objects.cache_module["xxx"] is None + assert "xxx" not in mkapi.objects.cache_module_node assert get_module("markdown") is not None - assert "markdown" in mkapi.ast.cache_module - assert "markdown" in mkapi.ast.cache_module_node + assert "markdown" in mkapi.objects.cache_module + assert "markdown" in mkapi.objects.cache_module_node -def test_source(): +def test_get_source(): module = get_module("mkdocs.structure.files") assert module assert "class File" in module.source + module = get_module("mkapi.plugins") + assert module + cls = module.get("MkAPIConfig") + assert cls.get_module() is module + assert cls.get_source().startswith("class MkAPIConfig") + assert "MkAPIPlugin" in module.get_source() diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 00000000..ec923abc --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,40 @@ +import ast + +from mkapi.objects import get_module + + +def test_(): + module = get_module("mkapi.plugins") + assert module + # source = module.source + # print(source) + cls = module.get("MkAPIConfig") + print(cls._node.lineno) + print(cls._node.end_lineno) + print(cls._node.__dict__) + print(cls.__module_name__) + # lines = source.split("\n") + # print("v" * 10) + # print("\n".join(lines[cls._node.lineno - 1 : cls._node.end_lineno - 1])) + # print("^" * 10) + assert 0 + # cls = module.get("MkAPIConfig") + # print(cls) + # x = module.get("Config") + # print(x) + # m = get_module_from_import(x) + # print(m) + # print(m.get("Config")) + # print(m.get("Config").unparse()) + + # print(ast.unparse(cls._node)) + # print(cls.attributes[0].default) + # g = module.get("config_options") + # assert g + # print(g) + # m = get_module_from_import(g) + # assert m + # print(m) + # print(cls.bases) + # print(cls._node.bases) + # assert 0 diff --git a/tests/test_modules.py b/tests/test_modules.py deleted file mode 100644 index e69de29b..00000000 From 601074eb29f6e8e65be49ea2a40c5736a438e6be Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 13:22:51 +0900 Subject: [PATCH 053/148] get_object --- src/mkapi/objects.py | 97 +++++++++++++++++++++++------------- tests/objects/test_object.py | 13 +++++ tests/test_inspect.py | 11 ++-- 3 files changed, 81 insertions(+), 40 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index e21f747e..6204fb9a 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -45,7 +45,8 @@ def __post_init__(self) -> None: self.__dict__["__module_name__"] = current_module_name[0] def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name})" + fullname = self.get_fullname() + return f"{self.__class__.__name__}({fullname})" def unparse(self) -> str: # noqa: D102 return ast.unparse(self._node) @@ -60,13 +61,20 @@ def get_module(self) -> Module | None: return get_module(module_name) return None - def get_source(self) -> str: + def get_source(self, maxline: int | None = None) -> str: """Return the source code.""" if (name := self.get_module_name()) and (source := _get_source(name)): - lines = source.split("\n") - return "\n".join(lines[self._node.lineno - 1 : self._node.end_lineno]) + lines = source.split("\n")[self._node.lineno - 1 : self._node.end_lineno] + if maxline: + lines = lines[:maxline] + return "\n".join(lines) return "" + def get_fullname(self) -> str: # noqa: D102 + if module_name := self.get_module_name(): + return f"{module_name}.{self.name}" + return self.name + @dataclass class Import(Object): # noqa: D101 @@ -75,8 +83,8 @@ class Import(Object): # noqa: D101 fullname: str from_: str | None - # def __repr__(self) -> str: - # return f"{self.__class__.__name__}({self.name}: {self.fullname})" + def get_fullname(self) -> str: # noqa: D102 + return self.fullname def iter_import_nodes(node: AST) -> Iterator[Import_]: @@ -250,18 +258,16 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] - def get(self, name: str) -> Class | Function: # noqa: D102 - names = [cls.name for cls in self.classes] - if name in names: - return self.classes[names.index(name)] - names = [func.name for func in self.functions] - if name in names: - return self.functions[names.index(name)] + def get(self, name: str) -> Class | Function | Attribute: # noqa: D102 + for attr in ["classes", "functions", "attributes"]: + for obj in getattr(self, attr): + if obj.name == name: + return obj raise NameError def iter_callable_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: - """Yield definition nodes.""" + """Yield callable nodes.""" for child in ast.iter_child_nodes(node): if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield child @@ -296,11 +302,11 @@ def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Functi """Return a tuple of (list[Class], list[Function]).""" classes: list[Class] = [] functions: list[Function] = [] - for definition in iter_callables(node): - if isinstance(definition, Class): - classes.append(definition) + for callable_ in iter_callables(node): + if isinstance(callable_, Class): + classes.append(callable_) else: - functions.append(definition) + functions.append(callable_) return classes, functions @@ -312,18 +318,16 @@ class Module(Object): # noqa: D101 functions: list[Function] source: str - def get(self, name: str) -> Class | Function | Import: # noqa: D102 - names = [cls.name for cls in self.classes] - if name in names: - return self.classes[names.index(name)] - names = [func.name for func in self.functions] - if name in names: - return self.functions[names.index(name)] - names = [imp.name for imp in self.imports] - if name in names: - return self.imports[names.index(name)] + def get(self, name: str) -> Class | Function | Attribute | Import: # noqa: D102 + for attr in ["classes", "functions", "attributes", "imports"]: + for obj in getattr(self, attr): + if obj.name == name: + return obj raise NameError + def get_fullname(self) -> str: # noqa: D102 + return self.name + def get_source(self) -> str: """Return the source code.""" return _get_source(self.name) if self.name else "" @@ -367,6 +371,8 @@ def get_module_node(name: str) -> ast.Module | None: if not spec or not spec.origin: return None path = Path(spec.origin) + if not path.exists(): # for builtin, frozen + return None mtime = path.stat().st_mtime if name in cache_module_node and mtime == cache_module_node[name][0]: return cache_module_node[name][1] @@ -385,13 +391,36 @@ def _get_source(name: str) -> str: return "" -def get_module_from_import(import_: Import) -> Module | None: - """Return a [Module] instance from [Import].""" - if module := get_module(import_.fullname): - return module - if import_.from_ and (module := get_module(import_.from_)): +# def get_module_from_import(import_: Import) -> Module | None: +# """Return a [Module] instance from [Import].""" +# if module := get_module(import_.fullname): +# return module +# if import_.from_ and (module := get_module(import_.from_)): +# return module +# return None + + +def get_object(fullname: str) -> Module | Class | Function | Attribute | None: + """Return a [Object] instance by name.""" + if module := get_module(fullname): return module - return None + if "." not in fullname: + return None + module_name, name = fullname.rsplit(".", maxsplit=1) + if not (module := get_module(module_name)): + return None + return get_object_from_module(name, module) + + +def get_object_from_module( + name: str, + module: Module, +) -> Module | Class | Function | Attribute | None: + """Return a [Object] instance by name from [Module].""" + obj = module.get(name) + if isinstance(obj, Import): + return get_object(obj.fullname) + return obj class Transformer(NodeTransformer): # noqa: D101 diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 3b73ec10..8be92112 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -7,6 +7,8 @@ from mkapi.objects import ( get_module, get_module_node, + get_object, + get_object_from_module, iter_callable_nodes, iter_import_nodes, iter_imports, @@ -73,6 +75,17 @@ def test_not_found(): assert "markdown" in mkapi.objects.cache_module_node +def test_repr(): + module = get_module("mkapi") + assert repr(module) == "Module(mkapi)" + module = get_module("mkapi.objects") + assert repr(module) == "Module(mkapi.objects)" + obj = get_object("mkapi.objects.Object") + assert repr(obj) == "Class(mkapi.objects.Object)" + obj = get_object("mkapi.plugins.BasePlugin") + assert repr(obj) == "Class(mkdocs.plugins.BasePlugin)" + + def test_get_source(): module = get_module("mkdocs.structure.files") assert module diff --git a/tests/test_inspect.py b/tests/test_inspect.py index ec923abc..cd68661a 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -8,16 +8,15 @@ def test_(): assert module # source = module.source # print(source) - cls = module.get("MkAPIConfig") - print(cls._node.lineno) - print(cls._node.end_lineno) - print(cls._node.__dict__) - print(cls.__module_name__) + # cls = module.get("MkAPIConfig") + # print(cls._node.lineno) + # print(cls._node.end_lineno) + # print(cls._node.__dict__) + # print(cls.__module_name__) # lines = source.split("\n") # print("v" * 10) # print("\n".join(lines[cls._node.lineno - 1 : cls._node.end_lineno - 1])) # print("^" * 10) - assert 0 # cls = module.get("MkAPIConfig") # print(cls) # x = module.get("Config") From 7ff6302f93b4918a19ed6067fb7ed825cffcd9f5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 15:46:32 +0900 Subject: [PATCH 054/148] merge docstring in module level attributes. --- src/mkapi/docstring.py | 31 +++++- src/mkapi/inspect.py | 69 ++++++++---- src/mkapi/objects.py | 102 ++++++++++-------- tests/docstring/__init__.py | 0 tests/docstring/test_google.py | 12 ++- tests/inspect/__init__.py | 0 tests/inspect/test_google.py | 41 +++++++ tests/{ => inspect}/test_inspect.py | 6 +- .../test_transformer.py} | 10 +- tests/objects/test_defs.py | 4 +- tests/objects/test_google.py | 14 +++ tests/objects/test_object.py | 17 ++- 12 files changed, 208 insertions(+), 98 deletions(-) create mode 100644 tests/docstring/__init__.py create mode 100644 tests/inspect/__init__.py create mode 100644 tests/inspect/test_google.py rename tests/{ => inspect}/test_inspect.py (87%) rename tests/{objects/test_eval.py => inspect/test_transformer.py} (84%) create mode 100644 tests/objects/test_google.py diff --git a/src/mkapi/docstring.py b/src/mkapi/docstring.py index bc19eda3..f0018a0d 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstring.py @@ -20,7 +20,7 @@ class Item: # noqa: D101 description: str def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name!r})" + return f"{self.__class__.__name__}({self.name})" SPLIT_ITEM_PATTERN = re.compile(r"\n\S") @@ -173,13 +173,34 @@ def split_attribute(docstring: str) -> tuple[str, str]: class Section(Item): # noqa: D101 items: list[Item] + def __iter__(self) -> Iterator[Item]: + return iter(self.items) -@dataclass + def get(self, name: str) -> Item | None: # noqa: D102 + for item in self.items: + if item.name == name: + return item + return None + + +@dataclass(repr=False) class Docstring(Item): # noqa: D101 sections: list[Section] + def __repr__(self) -> str: + return f"{self.__class__.__name__}(num_sections={len(self.sections)})" + + def __iter__(self) -> Iterator[Section]: + return iter(self.sections) + + def get(self, name: str) -> Section | None: # noqa: D102 + for section in self.sections: + if section.name == name: + return section + return None + -def get_docstring(doc: str, style: Style) -> Docstring: +def parse_docstring(doc: str, style: Style) -> Docstring: """Return a docstring instance.""" doc = add_fence(doc) sections: list[Section] = [] @@ -189,9 +210,11 @@ def get_docstring(doc: str, style: Style) -> Docstring: if name in ["Parameters", "Attributes", "Raises"]: items = list(iter_items(desc, style)) elif name in ["Returns", "Yields"]: - type_, desc_ = parse_return(desc, style) + type_, desc_ = split_return(desc, style) elif name in ["Note", "Notes", "Warning", "Warnings"]: desc_ = add_admonition(name, desc) + else: + desc_ = desc sections.append(Section(name, type_, desc_, items)) return Docstring("", "", "", sections) diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py index 2396648b..47fc68cb 100644 --- a/src/mkapi/inspect.py +++ b/src/mkapi/inspect.py @@ -1,24 +1,49 @@ +"""Inspect.""" from __future__ import annotations -from dataclasses import dataclass -from typing import TypeGuard - -from mkapi.objects import Class, Function, Import, Module, get_module - - -@dataclass(repr=False) -class Object: - """Object class.""" - - _obj: Module | Class | Function | Import - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self._obj})" - - def ismodule(self) -> bool: - return isinstance(self._obj, Module) - - -def get_object(name: str) -> Object: - if module := get_module(name): - x = Object(module) +import ast +from ast import Constant, Name, NodeTransformer +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class Transformer(NodeTransformer): # noqa: D101 + def _rename(self, name: str) -> Name: + return Name(id=f"__mkapi__.{name}") + + def visit_Name(self, node: Name) -> Name: # noqa: N802, D102 + return self._rename(node.id) + + def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 + return ast.unparse(self.visit(node)) + + +class StringTransformer(Transformer): # noqa: D101 + def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 + if isinstance(node.value, str): + return self._rename(node.value) + return node + + +def _iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: + """Yield identifiers as a tuple of (code, isidentifier).""" + start = 0 + while start < len(source): + index = source.find("__mkapi__.", start) + if index == -1: + yield source[start:], False + return + else: + if index != 0: + yield source[start:index], False + start = end = index + 10 # 10 == len("__mkapi__.") + while end < len(source): + s = source[end] + if s == "." or s.isdigit() or s.isidentifier(): + end += 1 + else: + break + yield source[start:end], True + start = end diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 6204fb9a..7920eac6 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast +import re from ast import ( AnnAssign, Assign, @@ -12,7 +13,6 @@ FunctionDef, ImportFrom, Name, - NodeTransformer, TypeAlias, ) from dataclasses import dataclass, field @@ -22,6 +22,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from mkapi.docstring import Style, parse_docstring, split_attribute, split_return + if TYPE_CHECKING: from ast import AST from collections.abc import Iterator @@ -45,8 +47,8 @@ def __post_init__(self) -> None: self.__dict__["__module_name__"] = current_module_name[0] def __repr__(self) -> str: - fullname = self.get_fullname() - return f"{self.__class__.__name__}({fullname})" + # fullname = self.get_fullname() + return f"{self.__class__.__name__}({self.name})" def unparse(self) -> str: # noqa: D102 return ast.unparse(self._node) @@ -168,7 +170,9 @@ def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: type_ = get_type(assign) value = None if isinstance(assign, TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, TypeAlias) else None - yield Attribute(assign, name, assign.__doc__, type_, value, type_params) + attr = Attribute(assign, name, assign.__doc__, type_, value, type_params) + _merge_docstring_attribute(attr) + yield attr @dataclass(repr=False) @@ -208,7 +212,12 @@ def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: it = _iter_defaults(node) for arg, kind in _iter_parameters(node): default = None if kind in [P.VAR_POSITIONAL, P.VAR_KEYWORD] else next(it) - yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) + name = arg.arg + if kind is P.VAR_POSITIONAL: + name = f"*{name}" + if kind is P.VAR_KEYWORD: + name = f"**{name}" + yield Parameter(arg, name, None, arg.annotation, default, kind) @dataclass(repr=False) @@ -216,6 +225,10 @@ class Raise(Object): # noqa: D101 _node: ast.Raise type: ast.expr | None # # noqa: A003 + def __repr__(self) -> str: + exc = ast.unparse(self.type) if self.type else "" + return f"{self.__class__.__name__}({exc})" + def iter_raises(node: FunctionDef_) -> Iterator[Raise]: """Yield raise nodes.""" @@ -258,12 +271,12 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] - def get(self, name: str) -> Class | Function | Attribute: # noqa: D102 + def get(self, name: str) -> Class | Function | Attribute | None: # noqa: D102 for attr in ["classes", "functions", "attributes"]: for obj in getattr(self, attr): if obj.name == name: return obj - raise NameError + return None def iter_callable_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: @@ -295,7 +308,10 @@ def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: yield Class(def_node, *args, [], bases, attrs, classes, functions) else: raises = list(iter_raises(def_node)) - yield Function(def_node, *args, raises, get_return(def_node)) + func = Function(def_node, *args, raises, get_return(def_node)) + # TODO: for property + # _merge_docstring_attribute + yield func def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: @@ -318,12 +334,12 @@ class Module(Object): # noqa: D101 functions: list[Function] source: str - def get(self, name: str) -> Class | Function | Attribute | Import: # noqa: D102 + def get(self, name: str) -> Class | Function | Attribute | Import | None: # noqa: D102 for attr in ["classes", "functions", "attributes", "imports"]: for obj in getattr(self, attr): if obj.name == name: return obj - raise NameError + return None def get_fullname(self) -> str: # noqa: D102 return self.name @@ -341,9 +357,9 @@ def get_module(name: str) -> Module | None: """Return a [Module] instance by name.""" if name in cache_module: return cache_module[name] - if node := get_module_node(name): + if node := _get_module_node(name): current_module_name[0] = name - module = get_module_from_node(node) + module = _get_module_from_node(node) current_module_name[0] = None module.name = name module.source = _get_source(name) @@ -353,7 +369,7 @@ def get_module(name: str) -> Module | None: return None -def get_module_from_node(node: ast.Module) -> Module: +def _get_module_from_node(node: ast.Module) -> Module: """Return a [Module] instance from [ast.Module] node.""" docstring = ast.get_docstring(node) imports = list(iter_imports(node)) @@ -362,7 +378,7 @@ def get_module_from_node(node: ast.Module) -> Module: return Module(node, "", docstring, imports, attrs, classes, functions, "") -def get_module_node(name: str) -> ast.Module | None: +def _get_module_node(name: str) -> ast.Module | None: """Return a [ast.Module] node by name.""" try: spec = find_spec(name) @@ -423,41 +439,35 @@ def get_object_from_module( return obj -class Transformer(NodeTransformer): # noqa: D101 - def _rename(self, name: str) -> Name: - return Name(id=f"__mkapi__.{name}") +SPLIT_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") - def visit_Name(self, node: Name) -> Name: # noqa: N802, D102 - return self._rename(node.id) - def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 - return ast.unparse(self.visit(node)) +def _split_name(name: str) -> list[str]: + return [x for x in re.split(SPLIT_PATTERN, name) if x] -class StringTransformer(Transformer): # noqa: D101 - def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 - if isinstance(node.value, str): - return self._rename(node.value) - return node +def _is_identifier(name: str) -> bool: + return name != "" and all(x.isidentifier() for x in _split_name(name)) -def _iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: - """Yield identifiers as a tuple of (code, isidentifier).""" - start = 0 - while start < len(source): - index = source.find("__mkapi__.", start) - if index == -1: - yield source[start:], False - return - else: - if index != 0: - yield source[start:index], False - start = end = index + 10 # 10 == len("__mkapi__.") - while end < len(source): - s = source[end] - if s == "." or s.isdigit() or s.isidentifier(): - end += 1 - else: - break - yield source[start:end], True - start = end +def _to_expr(name: str) -> ast.expr: + if _is_identifier(name): + name = name.replace("(", "[").replace(")", "]") + expr = ast.parse(name).body[0] + if isinstance(expr, ast.Expr): + return expr.value + return Constant(value=name) + + +def _merge_docstring_attribute(obj: Attribute) -> None: + if doc := obj.docstring: + type_, desc = split_attribute(doc) + if not obj.type and type_: + obj.type = _to_expr(type_) + obj.docstring = desc + + +# def merge_docstring(obj: Object, style: Style) -> None: +# if isinstance(obj, Attribute): +# return _merge_docstring_attribute(obj) +# if isinstance(obj, diff --git a/tests/docstring/__init__.py b/tests/docstring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/docstring/test_google.py b/tests/docstring/test_google.py index 0ab4ecf0..c3849bc7 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstring/test_google.py @@ -2,12 +2,13 @@ _iter_items, iter_items, iter_sections, + parse_docstring, split_attribute, split_item, split_return, split_section, ) -from mkapi.objects import Module +from mkapi.objects import Module, get_object_from_module def test_split_section(): @@ -113,7 +114,7 @@ def test_iter_items_class(google): assert x[1].description == "Description of `param2`. Multiple\nlines are supported." -def test_get_return(google): +def test_split_return(google): doc = google.get("module_level_function").docstring assert isinstance(doc, str) section = list(iter_sections(doc, "google"))[2][1] @@ -123,7 +124,7 @@ def test_get_return(google): assert x[1].endswith(" }") -def test_parse_attribute(google): +def test_split_attribute(google): doc = google.get("ExampleClass").get("readonly_property").docstring assert isinstance(doc, str) x = split_attribute(doc) @@ -135,3 +136,8 @@ def test_parse_attribute(google): assert x[0] == "list(str)" assert x[1].startswith("Properties with both") assert x[1].endswith("mentioned here.") + + +def test_repr(google): + r = repr(parse_docstring(google.docstring, "google")) + assert r == "Docstring(num_sections=6)" diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/inspect/test_google.py b/tests/inspect/test_google.py new file mode 100644 index 00000000..8a4d902e --- /dev/null +++ b/tests/inspect/test_google.py @@ -0,0 +1,41 @@ +import ast + +# from mkapi.docstring import get_docstring +# from mkapi.objects import get_module + + +def test_google(google): + print(google) + print(google.docstring) + print(google.attributes) + # print(google.functions) + # print(google.attributes) + # assert 0 + # print(cls._node.lineno) + # print(cls._node.end_lineno) + # print(cls._node.__dict__) + # print(cls.__module_name__) + # lines = source.split("\n") + # print("v" * 10) + # print("\n".join(lines[cls._node.lineno - 1 : cls._node.end_lineno - 1])) + # print("^" * 10) + # cls = module.get("MkAPIConfig") + # print(cls) + # x = module.get("Config") + # print(x) + # m = get_module_from_import(x) + # print(m) + # print(m.get("Config")) + # print(m.get("Config").unparse()) + + # print(ast.unparse(cls._node)) + # print(cls.attributes[0].default) + # g = module.get("config_options") + # assert g + # print(g) + # m = get_module_from_import(g) + # assert m + # print(m) + # print(cls.bases) + # print(cls._node.bases) + # assert 0 diff --git a/tests/test_inspect.py b/tests/inspect/test_inspect.py similarity index 87% rename from tests/test_inspect.py rename to tests/inspect/test_inspect.py index cd68661a..c01baf23 100644 --- a/tests/test_inspect.py +++ b/tests/inspect/test_inspect.py @@ -6,9 +6,9 @@ def test_(): module = get_module("mkapi.plugins") assert module - # source = module.source - # print(source) - # cls = module.get("MkAPIConfig") + cls = module.get("MkAPIPlugin") + print(cls.get_source(10)) + # assert 0 # print(cls._node.lineno) # print(cls._node.end_lineno) # print(cls._node.__dict__) diff --git a/tests/objects/test_eval.py b/tests/inspect/test_transformer.py similarity index 84% rename from tests/objects/test_eval.py rename to tests/inspect/test_transformer.py index 661a21c8..83cd480e 100644 --- a/tests/objects/test_eval.py +++ b/tests/inspect/test_transformer.py @@ -2,11 +2,10 @@ import pytest +from mkapi.inspect import StringTransformer, _iter_identifiers from mkapi.objects import ( Function, Module, - StringTransformer, - _iter_identifiers, get_module, ) @@ -68,10 +67,3 @@ def test_functions(module: Module): assert isinstance(func, Function) type_ = func.parameters[0].type assert isinstance(type_, ast.expr) - # text = StringTransformer().unparse(type_) - # for s in _iter_identifiers(text): - # print(s) - # print(module.imports) - # print(module.classes) - # print(module.attributes) - # print(module.functions) diff --git a/tests/objects/test_defs.py b/tests/objects/test_defs.py index 8eb622f4..5698c329 100644 --- a/tests/objects/test_defs.py +++ b/tests/objects/test_defs.py @@ -1,10 +1,10 @@ import ast -from mkapi.objects import Class, get_module_from_node +from mkapi.objects import Class, _get_module_from_node def _get(src: str): - return get_module_from_node(ast.parse(src)) + return _get_module_from_node(ast.parse(src)) def test_deco(): diff --git a/tests/objects/test_google.py b/tests/objects/test_google.py new file mode 100644 index 00000000..15109a75 --- /dev/null +++ b/tests/objects/test_google.py @@ -0,0 +1,14 @@ +import ast + +from mkapi.objects import Attribute + + +def test_parse(google): + name = "module_level_variable2" + node = google.get(name) + assert isinstance(node, Attribute) + assert isinstance(node.docstring, str) + assert node.docstring.startswith("Module level") + assert node.docstring.endswith("by a colon.") + assert isinstance(node.type, ast.Name) + assert node.type.id == "int" diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 8be92112..3a9ecfa5 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -5,10 +5,9 @@ import mkapi.objects from mkapi.objects import ( + _get_module_node, get_module, - get_module_node, get_object, - get_object_from_module, iter_callable_nodes, iter_import_nodes, iter_imports, @@ -16,13 +15,13 @@ def test_get_module_node(): - node = get_module_node("mkdocs") + node = _get_module_node("mkdocs") assert isinstance(node, Module) def test_module_cache(): - node1 = get_module_node("mkdocs") - node2 = get_module_node("mkdocs") + node1 = _get_module_node("mkdocs") + node2 = _get_module_node("mkdocs") assert node1 is node2 module1 = get_module("mkapi") module2 = get_module("mkapi") @@ -31,7 +30,7 @@ def test_module_cache(): @pytest.fixture(scope="module") def module(): - return get_module_node("mkdocs.structure.files") + return _get_module_node("mkdocs.structure.files") def test_iter_import_nodes(module: Module): @@ -66,7 +65,7 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): - assert get_module_node("xxx") is None + assert _get_module_node("xxx") is None assert get_module("xxx") is None assert mkapi.objects.cache_module["xxx"] is None assert "xxx" not in mkapi.objects.cache_module_node @@ -81,9 +80,9 @@ def test_repr(): module = get_module("mkapi.objects") assert repr(module) == "Module(mkapi.objects)" obj = get_object("mkapi.objects.Object") - assert repr(obj) == "Class(mkapi.objects.Object)" + assert repr(obj) == "Class(Object)" obj = get_object("mkapi.plugins.BasePlugin") - assert repr(obj) == "Class(mkdocs.plugins.BasePlugin)" + assert repr(obj) == "Class(BasePlugin)" def test_get_source(): From e32b46208ec0f36b16ec509d82c397c1f195e63c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 4 Jan 2024 22:06:49 +0900 Subject: [PATCH 055/148] merge functions --- pyproject.toml | 1 + src/mkapi/{docstring.py => docstrings.py} | 134 +++++----------- src/mkapi/objects.py | 145 +++++++++++++++--- src/mkapi/plugins.py | 4 +- src/mkapi/utils.py | 7 + src_old/mkapi/core/module.py | 2 +- src_old/mkapi/core/node.py | 8 +- src_old/mkapi/core/object.py | 2 +- src_old/mkapi/core/structure.py | 2 +- tests/{docstring => docstrings}/__init__.py | 0 .../{docstring => docstrings}/test_google.py | 41 ++--- tests/{docstring => docstrings}/test_numpy.py | 41 ++--- tests/objects/{test_args.py => test_arg.py} | 0 tests/objects/{test_attrs.py => test_attr.py} | 0 .../{test_defs.py => test_callable.py} | 0 tests/objects/test_google.py | 24 ++- tests/test_utils.py | 13 +- 17 files changed, 235 insertions(+), 189 deletions(-) rename src/mkapi/{docstring.py => docstrings.py} (74%) rename tests/{docstring => docstrings}/__init__.py (100%) rename tests/{docstring => docstrings}/test_google.py (75%) rename tests/{docstring => docstrings}/test_numpy.py (74%) rename tests/objects/{test_args.py => test_arg.py} (100%) rename tests/objects/{test_attrs.py => test_attr.py} (100%) rename tests/objects/{test_defs.py => test_callable.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index d4f8149d..8110a9e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ ignore = [ "D407", "ERA001", "N812", + "PGH003", ] [tool.ruff.extend-per-file-ignores] diff --git a/src/mkapi/docstring.py b/src/mkapi/docstrings.py similarity index 74% rename from src/mkapi/docstring.py rename to src/mkapi/docstrings.py index f0018a0d..6b9f04da 100644 --- a/src/mkapi/docstring.py +++ b/src/mkapi/docstrings.py @@ -1,4 +1,4 @@ -"""Parse docstring.""" +"""Parse docstrings.""" from __future__ import annotations import re @@ -69,13 +69,27 @@ def iter_items(section: str, style: Style) -> Iterator[Item]: yield Item(*split_item(item, style)) +@dataclass +class Section(Item): # noqa: D101 + items: list[Item] + + def __iter__(self) -> Iterator[Item]: + return iter(self.items) + + def get(self, name: str) -> Item | None: # noqa: D102 + for item in self.items: + if item.name == name: + return item + return None + + SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { "google": re.compile(r"\n\n\S"), "numpy": re.compile(r"\n\n\n|\n\n.+?\n-+\n"), } -def _iter_sections(doc: str, style: Style) -> Iterator[str]: +def _split_sections(doc: str, style: Style) -> Iterator[str]: pattern = SPLIT_SECTION_PATTERNS[style] if not (m := re.search("\n\n", doc)): yield doc.strip() @@ -131,10 +145,10 @@ def split_section(section: str, style: Style) -> tuple[str, str]: return "", section -def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: +def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: """Yield (section name, description) pairs by splitting the whole docstring.""" prev_name, prev_desc = "", "" - for section in _iter_sections(doc, style): + for section in _split_sections(doc, style): if not section: continue name, desc = split_section(section, style) @@ -152,6 +166,22 @@ def iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_desc +def iter_sections(doc: str, style: Style) -> Iterator[Section]: + """Yield [Section] instance by splitting the whole docstring.""" + for name, desc in _iter_sections(doc, style): + type_ = desc_ = "" + items: list[Item] = [] + if name in ["Parameters", "Attributes", "Raises"]: + items = list(iter_items(desc, style)) + elif name in ["Returns", "Yields"]: + type_, desc_ = split_return(desc, style) + elif name in ["Note", "Notes", "Warning", "Warnings"]: + desc_ = add_admonition(name, desc) + else: + desc_ = desc + yield Section(name, type_, desc_, items) + + def split_return(section: str, style: Style) -> tuple[str, str]: """Return a tuple of (type, description) for Returns and Yields section.""" lines = section.split("\n") @@ -163,26 +193,12 @@ def split_return(section: str, style: Style) -> tuple[str, str]: return "", section -# for mkapi.ast.Attribute.docstring +# for mkapi.objects.Attribute.docstring def split_attribute(docstring: str) -> tuple[str, str]: """Return a tuple of (type, description) for Attribute docstring.""" return split_return(docstring, "google") -@dataclass -class Section(Item): # noqa: D101 - items: list[Item] - - def __iter__(self) -> Iterator[Item]: - return iter(self.items) - - def get(self, name: str) -> Item | None: # noqa: D102 - for item in self.items: - if item.name == name: - return item - return None - - @dataclass(repr=False) class Docstring(Item): # noqa: D101 sections: list[Section] @@ -201,83 +217,7 @@ def get(self, name: str) -> Section | None: # noqa: D102 def parse_docstring(doc: str, style: Style) -> Docstring: - """Return a docstring instance.""" + """Return a [Docstring] instance.""" doc = add_fence(doc) - sections: list[Section] = [] - for name, desc in iter_sections(doc, style): - type_ = desc_ = "" - items: list[Item] = [] - if name in ["Parameters", "Attributes", "Raises"]: - items = list(iter_items(desc, style)) - elif name in ["Returns", "Yields"]: - type_, desc_ = split_return(desc, style) - elif name in ["Note", "Notes", "Warning", "Warnings"]: - desc_ = add_admonition(name, desc) - else: - desc_ = desc - sections.append(Section(name, type_, desc_, items)) + sections = list(iter_sections(doc, style)) return Docstring("", "", "", sections) - - -# @dataclass -# class Base: -# """Base class.""" - -# name: str -# """Name of item.""" -# docstring: str | None -# """Docstring of item.""" - -# def __repr__(self) -> str: -# return f"{self.__class__.__name__}({self.name!r})" - - -# @dataclass -# class Nodes[T]: -# """Collection of [Node] instance.""" - -# items: list[T] - -# def __getitem__(self, index: int | str) -> T: -# if isinstance(index, int): -# return self.items[index] -# names = [item.name for item in self.items] # type: ignore # noqa: PGH003 -# return self.items[names.index(index)] - -# def __getattr__(self, name: str) -> T: -# return self[name] - -# def __iter__(self) -> Iterator[T]: -# return iter(self.items) - -# def __contains__(self, name: str) -> bool: -# return any(name == item.name for item in self.items) # type: ignore # noqa: PGH003 - -# def __repr__(self) -> str: -# names = ", ".join(f"{item.name!r}" for item in self.items) # type: ignore # noqa: PGH003 -# return f"{self.__class__.__name__}({names})" - - -# @dataclass(repr=False) -# class Import(Node): -# """Import class.""" - -# _node: ast.Import | ImportFrom -# fullanme: str - - -# @dataclass -# class Imports(Nodes[Import]): -# """Imports class.""" - - -# self.markdown = add_fence(self.markdown) -# def postprocess_sections(sections: list[Section]) -> None: -# for section in sections: -# if section.name in ["Note", "Notes", "Warning", "Warnings"]: -# markdown = add_admonition(section.name, section.markdown) -# if sections and sections[-1].name == "": -# sections[-1].markdown += "\n\n" + markdown -# continue -# section.name = "" -# section.markdown = markdown diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 7920eac6..85e255f6 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -22,19 +22,23 @@ from pathlib import Path from typing import TYPE_CHECKING -from mkapi.docstring import Style, parse_docstring, split_attribute, split_return +from mkapi.docstrings import SECTION_NAMES, parse_docstring, split_attribute +from mkapi.utils import get_by_name if TYPE_CHECKING: from ast import AST from collections.abc import Iterator from inspect import _ParameterKind + from mkapi.docstrings import Docstring, Item, Section, Style + type Import_ = ast.Import | ImportFrom type FunctionDef_ = AsyncFunctionDef | FunctionDef type Def = FunctionDef_ | ClassDef type Assign_ = Assign | AnnAssign | TypeAlias current_module_name: list[str | None] = [None] +current_docstring_style: list[Style] = ["google"] @dataclass @@ -44,12 +48,16 @@ class Object: # noqa: D101 docstring: str | None def __post_init__(self) -> None: + # Set parent module. self.__dict__["__module_name__"] = current_module_name[0] def __repr__(self) -> str: # fullname = self.get_fullname() return f"{self.__class__.__name__}({self.name})" + def get_node(self) -> AST: # noqa: D102 + return self._node + def unparse(self) -> str: # noqa: D102 return ast.unparse(self._node) @@ -110,7 +118,7 @@ def iter_imports(node: ast.Module) -> Iterator[Import]: @dataclass(repr=False) class Attribute(Object): # noqa: D101 - _node: Assign_ + _node: Assign_ | FunctionDef_ type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None @@ -251,6 +259,7 @@ def get_return(node: FunctionDef_) -> Return: @dataclass(repr=False) class Callable(Object): # noqa: D101 + docstring: str | Docstring | None parameters: list[Parameter] decorators: list[ast.expr] type_params: list[ast.type_param] @@ -262,6 +271,9 @@ class Function(Callable): # noqa: D101 _node: FunctionDef_ returns: Return + def get_node(self) -> FunctionDef_: # noqa: D102 + return self._node + @dataclass(repr=False) class Class(Callable): # noqa: D101 @@ -271,8 +283,8 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] - def get(self, name: str) -> Class | Function | Attribute | None: # noqa: D102 - for attr in ["classes", "functions", "attributes"]: + def get(self, name: str) -> Attribute | Class | Function | None: # noqa: D102 + for attr in ["attributes", "classes", "functions"]: for obj in getattr(self, attr): if obj.name == name: return obj @@ -304,14 +316,13 @@ def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: if isinstance(def_node, ClassDef): attrs = list(iter_attributes(def_node)) classes, functions = get_callables(def_node) - bases: list[Class] = [] # TODO: use def_node.bases - yield Class(def_node, *args, [], bases, attrs, classes, functions) + bases: list[Class] = [] + cls = Class(def_node, *args, [], bases, attrs, classes, functions) + _move_property(cls) + yield cls else: raises = list(iter_raises(def_node)) - func = Function(def_node, *args, raises, get_return(def_node)) - # TODO: for property - # _merge_docstring_attribute - yield func + yield Function(def_node, *args, raises, get_return(def_node)) def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: @@ -328,14 +339,15 @@ def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Functi @dataclass(repr=False) class Module(Object): # noqa: D101 + docstring: str | Docstring | None imports: list[Import] attributes: list[Attribute] classes: list[Class] functions: list[Function] source: str - def get(self, name: str) -> Class | Function | Attribute | Import | None: # noqa: D102 - for attr in ["classes", "functions", "attributes", "imports"]: + def get(self, name: str) -> Import | Attribute | Class | Function | None: # noqa: D102 + for attr in ["imports", "attributes", "classes", "functions"]: for obj in getattr(self, attr): if obj.name == name: return obj @@ -407,15 +419,6 @@ def _get_source(name: str) -> str: return "" -# def get_module_from_import(import_: Import) -> Module | None: -# """Return a [Module] instance from [Import].""" -# if module := get_module(import_.fullname): -# return module -# if import_.from_ and (module := get_module(import_.from_)): -# return module -# return None - - def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by name.""" if module := get_module(fullname): @@ -439,11 +442,11 @@ def get_object_from_module( return obj -SPLIT_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") +SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") def _split_name(name: str) -> list[str]: - return [x for x in re.split(SPLIT_PATTERN, name) if x] + return [x for x in re.split(SPLIT_IDENTIFIER_PATTERN, name) if x] def _is_identifier(name: str) -> bool: @@ -452,7 +455,7 @@ def _is_identifier(name: str) -> bool: def _to_expr(name: str) -> ast.expr: if _is_identifier(name): - name = name.replace("(", "[").replace(")", "]") + name = name.replace("(", "[").replace(")", "]") # ex. list(str) -> list[str] expr = ast.parse(name).body[0] if isinstance(expr, ast.Expr): return expr.value @@ -467,6 +470,100 @@ def _merge_docstring_attribute(obj: Attribute) -> None: obj.docstring = desc +def _is_property(obj: Function) -> bool: + return any(ast.unparse(deco).startswith("property") for deco in obj.decorators) + + +def _move_property(obj: Class) -> None: + funcs: list[Function] = [] + for func in obj.functions: + if not _is_property(func): + funcs.append(func) + continue + node = func.get_node() + doc = func.docstring if isinstance(func.docstring, str) else "" + type_ = func.returns.type + type_params = func.type_params + attr = Attribute(node, func.name, doc, type_, None, type_params) + _merge_docstring_attribute(attr) + obj.attributes.append(attr) + obj.functions = funcs + + +def _get_style(doc: str) -> Style: + for names in SECTION_NAMES: + for name in names: + if f"\n\n{name}\n----" in doc: + current_docstring_style[0] = "numpy" + return "numpy" + current_docstring_style[0] = "google" + return "google" + + +def _merge_docstring_attributes(obj: Module | Class, section: Section) -> None: + print("----------------Attributes----------------") + names = set([x.name for x in section.items] + [x.name for x in obj.attributes]) + print(names) + attrs: list[Attribute] = [] + for name in names: + if not (attr := get_by_name(obj.attributes, name)): + attr = Attribute(None, name, None, None, None, []) # type: ignore + attrs.append(attr) + if not (item := get_by_name(section.items, name)): + continue + item # TODO + obj.attributes = attrs + + +def _merge_docstring_parameters(obj: Class | Function, section: Section) -> None: + print("----------------Parameters----------------") + names = set([x.name for x in section.items] + [x.name for x in obj.parameters]) + print(names) + for item in section: + print(item) + for attr in obj.parameters: + print(attr) + + +def _merge_docstring_raises(obj: Class | Function, section: Section) -> None: + print("----------------Raises----------------") + # Fix raises.name + names = set([x.name for x in section.items] + [x.name for x in obj.raises]) + print(names) + + +def _merge_docstring_returns(obj: Function, section: Section) -> None: + print("----------------Returns----------------") + print(section.description) + print(obj.returns) + + +def merge_docstring(obj: Module | Class | Function) -> None: + """Merge [Object] and [Docstring].""" + sections: list[Section] = [] + if not (doc := obj.docstring) or not isinstance(doc, str): + return + style = _get_style(doc) + docstring = parse_docstring(doc, style) + for section in docstring: + if section.name == "Attributes" and isinstance(obj, Module | Class): + _merge_docstring_attributes(obj, section) + elif section.name == "Parameters" and isinstance(obj, Class | Function): + _merge_docstring_parameters(obj, section) + elif section.name == "Raises" and isinstance(obj, Class | Function): + _merge_docstring_raises(obj, section) + elif section.name in ["Returns", "Yields"] and isinstance(obj, Function): + _merge_docstring_returns(obj, section) + else: + sections.append(section) + docstring.sections = sections + obj.docstring = docstring + + +# def _resolve_bases(obj: Class) -> None: +# obj.bases = [obj.get_module_name()] + + # def merge_docstring(obj: Object, style: Style) -> None: # if isinstance(obj, Attribute): # return _merge_docstring_attribute(obj) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 7a9c4916..28b77e53 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -123,7 +123,7 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: AR # ) -> str: # """Merge HTML and MkAPI's node structure.""" # if page.title: - # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # noqa: PGH003 + # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore # abs_src_path = page.file.abs_src_path # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] # return mkapi_page.content(html) @@ -311,7 +311,7 @@ def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig # """Clean page title.""" # title = str(page.title) # if title.startswith("![mkapi]("): -# page.title = title[9:-1].split("|")[0] # type: ignore # noqa: PGH003 +# page.title = title[9:-1].split("|")[0] # type: ignore # def _rmtree(path: Path) -> None: diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 9e4a8042..28f4f7d9 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -144,3 +144,10 @@ def add_admonition(name: str, markdown: str) -> str: lines = [" " + line if line else "" for line in markdown.split("\n")] lines.insert(0, f'!!! {kind} "{name}"') return "\n".join(lines) + + +def get_by_name[T](items: list[T], name: str) -> T | None: # noqa: D103 + for item in items: + if getattr(item, "name", None) == name: + return item + return None diff --git a/src_old/mkapi/core/module.py b/src_old/mkapi/core/module.py index a2c11be3..8ad03e1c 100644 --- a/src_old/mkapi/core/module.py +++ b/src_old/mkapi/core/module.py @@ -66,7 +66,7 @@ def get_markdown(self, filters: list[str]) -> str: def get_members(obj: object) -> list[Module]: """Return members.""" try: - sourcefile = inspect.getsourcefile(obj) # type: ignore # noqa: PGH003 + sourcefile = inspect.getsourcefile(obj) # type: ignore except TypeError: return [] if not sourcefile: diff --git a/src_old/mkapi/core/node.py b/src_old/mkapi/core/node.py index f789adb3..44ae9d6c 100644 --- a/src_old/mkapi/core/node.py +++ b/src_old/mkapi/core/node.py @@ -127,7 +127,7 @@ def is_abstract(obj: object) -> bool: """Return true if `obj` is abstract.""" if inspect.isabstract(obj): return True - if hasattr(obj, "__isabstractmethod__") and obj.__isabstractmethod__: # type: ignore # noqa: PGH003 + if hasattr(obj, "__isabstractmethod__") and obj.__isabstractmethod__: # type: ignore return True return False @@ -142,7 +142,7 @@ def has_self(obj: object) -> bool: def get_kind_self(obj: object) -> str: # noqa: D103 try: - self = obj.__self__ # type: ignore # noqa: PGH003 + self = obj.__self__ # type: ignore except KeyError: return "" if isinstance(self, type) or type(type(self)): # Issue#18 @@ -204,7 +204,7 @@ def is_member(obj: object, name: str = "", sourcefiles: list[str] | None = None) if not get_kind(obj): return -1 try: - sourcefile = inspect.getsourcefile(obj) # type: ignore # noqa: PGH003 + sourcefile = inspect.getsourcefile(obj) # type: ignore except TypeError: return -1 if not sourcefile: @@ -248,4 +248,4 @@ def get_node_from_module(name: str | object) -> None: from mkapi.core.module import modules obj = get_object(name) if isinstance(name, str) else name - return modules[obj.__module__].node[obj.__qualname__] # type: ignore # noqa: PGH003 + return modules[obj.__module__].node[obj.__qualname__] # type: ignore diff --git a/src_old/mkapi/core/object.py b/src_old/mkapi/core/object.py index 30fcb7a8..1f2d734e 100644 --- a/src_old/mkapi/core/object.py +++ b/src_old/mkapi/core/object.py @@ -147,7 +147,7 @@ def get_sourcefiles(obj: object) -> list[str]: sourfiles = [] for obj in objs: try: - sourcefile = inspect.getsourcefile(obj) or "" # type: ignore # noqa: PGH003 + sourcefile = inspect.getsourcefile(obj) or "" # type: ignore except TypeError: pass else: diff --git a/src_old/mkapi/core/structure.py b/src_old/mkapi/core/structure.py index 1c4a14f0..33fdc1b9 100644 --- a/src_old/mkapi/core/structure.py +++ b/src_old/mkapi/core/structure.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Self -from mkapi.docstring import Base, Docstring, Type, parse_docstring +from mkapi.docstrings import Base, Docstring, Type, parse_docstring if TYPE_CHECKING: from collections.abc import Iterator diff --git a/tests/docstring/__init__.py b/tests/docstrings/__init__.py similarity index 100% rename from tests/docstring/__init__.py rename to tests/docstrings/__init__.py diff --git a/tests/docstring/test_google.py b/tests/docstrings/test_google.py similarity index 75% rename from tests/docstring/test_google.py rename to tests/docstrings/test_google.py index c3849bc7..ecc4b232 100644 --- a/tests/docstring/test_google.py +++ b/tests/docstrings/test_google.py @@ -1,9 +1,8 @@ -from mkapi.docstring import ( +from mkapi.docstrings import ( _iter_items, + _iter_sections, iter_items, - iter_sections, parse_docstring, - split_attribute, split_item, split_return, split_section, @@ -23,20 +22,20 @@ def test_split_section(): def test_iter_sections_short(): - sections = list(iter_sections("", "google")) + sections = list(_iter_sections("", "google")) assert sections == [] - sections = list(iter_sections("x", "google")) + sections = list(_iter_sections("x", "google")) assert sections == [("", "x")] - sections = list(iter_sections("x\n", "google")) + sections = list(_iter_sections("x\n", "google")) assert sections == [("", "x")] - sections = list(iter_sections("x\n\n", "google")) + sections = list(_iter_sections("x\n\n", "google")) assert sections == [("", "x")] def test_iter_sections(google: Module): doc = google.docstring assert isinstance(doc, str) - sections = list(iter_sections(doc, "google")) + sections = list(_iter_sections(doc, "google")) assert len(sections) == 6 assert sections[0][1].startswith("Example Google") assert sections[0][1].endswith("indented text.") @@ -58,7 +57,7 @@ def test_iter_sections(google: Module): def test_iter_items(google: Module): doc = google.get("module_level_function").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "google"))[1][1] + section = list(_iter_sections(doc, "google"))[1][1] items = list(_iter_items(section)) assert len(items) == 4 assert items[0].startswith("param1") @@ -67,7 +66,7 @@ def test_iter_items(google: Module): assert items[3].startswith("**kwargs") doc = google.docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "google"))[3][1] + section = list(_iter_sections(doc, "google"))[3][1] items = list(_iter_items(section)) assert len(items) == 1 assert items[0].startswith("module_") @@ -76,7 +75,7 @@ def test_iter_items(google: Module): def test_split_item(google): doc = google.get("module_level_function").docstring assert isinstance(doc, str) - sections = list(iter_sections(doc, "google")) + sections = list(_iter_sections(doc, "google")) section = sections[1][1] items = list(_iter_items(section)) x = split_item(items[0], "google") @@ -96,7 +95,7 @@ def test_split_item(google): def test_iter_items_class(google): doc = google.get("ExampleClass").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "google"))[1][1] + section = list(_iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) assert x[0].name == "attr1" assert x[0].type == "str" @@ -106,7 +105,7 @@ def test_iter_items_class(google): assert x[1].description == "Description of `attr2`." doc = google.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "google"))[2][1] + section = list(_iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) assert x[0].name == "param1" assert x[0].type == "str" @@ -117,27 +116,13 @@ def test_iter_items_class(google): def test_split_return(google): doc = google.get("module_level_function").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "google"))[2][1] + section = list(_iter_sections(doc, "google"))[2][1] x = split_return(section, "google") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") -def test_split_attribute(google): - doc = google.get("ExampleClass").get("readonly_property").docstring - assert isinstance(doc, str) - x = split_attribute(doc) - assert x[0] == "str" - assert x[1] == "Properties should be documented in their getter method." - doc = google.get("ExampleClass").get("readwrite_property").docstring - assert isinstance(doc, str) - x = split_attribute(doc) - assert x[0] == "list(str)" - assert x[1].startswith("Properties with both") - assert x[1].endswith("mentioned here.") - - def test_repr(google): r = repr(parse_docstring(google.docstring, "google")) assert r == "Docstring(num_sections=6)" diff --git a/tests/docstring/test_numpy.py b/tests/docstrings/test_numpy.py similarity index 74% rename from tests/docstring/test_numpy.py rename to tests/docstrings/test_numpy.py index 622e3e04..45359481 100644 --- a/tests/docstring/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -1,8 +1,7 @@ -from mkapi.docstring import ( +from mkapi.docstrings import ( _iter_items, + _iter_sections, iter_items, - iter_sections, - split_attribute, split_item, split_return, split_section, @@ -19,20 +18,20 @@ def test_split_section(): def test_iter_sections_short(): - sections = list(iter_sections("", "numpy")) + sections = list(_iter_sections("", "numpy")) assert sections == [] - sections = list(iter_sections("x", "numpy")) + sections = list(_iter_sections("x", "numpy")) assert sections == [("", "x")] - sections = list(iter_sections("x\n", "numpy")) + sections = list(_iter_sections("x\n", "numpy")) assert sections == [("", "x")] - sections = list(iter_sections("x\n\n", "numpy")) + sections = list(_iter_sections("x\n\n", "numpy")) assert sections == [("", "x")] def test_iter_sections(numpy: Module): doc = numpy.docstring assert isinstance(doc, str) - sections = list(iter_sections(doc, "numpy")) + sections = list(_iter_sections(doc, "numpy")) assert len(sections) == 7 assert sections[0][1].startswith("Example NumPy") assert sections[0][1].endswith("equal length.") @@ -56,7 +55,7 @@ def test_iter_sections(numpy: Module): def test_iter_items(numpy: Module): doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "numpy"))[1][1] + section = list(_iter_sections(doc, "numpy"))[1][1] items = list(_iter_items(section)) assert len(items) == 4 assert items[0].startswith("param1") @@ -65,7 +64,7 @@ def test_iter_items(numpy: Module): assert items[3].startswith("**kwargs") doc = numpy.docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "numpy"))[5][1] + section = list(_iter_sections(doc, "numpy"))[5][1] items = list(_iter_items(section)) assert len(items) == 1 assert items[0].startswith("module_") @@ -74,7 +73,7 @@ def test_iter_items(numpy: Module): def test_split_item(numpy): doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) - sections = list(iter_sections(doc, "numpy")) + sections = list(_iter_sections(doc, "numpy")) items = list(_iter_items(sections[1][1])) x = split_item(items[0], "numpy") assert x == ("param1", "int", "The first parameter.") @@ -92,7 +91,7 @@ def test_split_item(numpy): def test_iter_items_class(numpy): doc = numpy.get("ExampleClass").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "numpy"))[1][1] + section = list(_iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) assert x[0].name == "attr1" assert x[0].type == "str" @@ -102,7 +101,7 @@ def test_iter_items_class(numpy): assert x[1].description == "Description of `attr2`." doc = numpy.get("ExampleClass").get("__init__").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "numpy"))[2][1] + section = list(_iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) assert x[0].name == "param1" assert x[0].type == "str" @@ -113,22 +112,8 @@ def test_iter_items_class(numpy): def test_get_return(numpy): doc = numpy.get("module_level_function").docstring assert isinstance(doc, str) - section = list(iter_sections(doc, "numpy"))[2][1] + section = list(_iter_sections(doc, "numpy"))[2][1] x = split_return(section, "numpy") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") - - -def test_parse_attribute(numpy): - doc = numpy.get("ExampleClass").get("readonly_property").docstring - assert isinstance(doc, str) - x = split_attribute(doc) - assert x[0] == "str" - assert x[1] == "Properties should be documented in their getter method." - doc = numpy.get("ExampleClass").get("readwrite_property").docstring - assert isinstance(doc, str) - x = split_attribute(doc) - assert x[0] == "list(str)" - assert x[1].startswith("Properties with both") - assert x[1].endswith("mentioned here.") diff --git a/tests/objects/test_args.py b/tests/objects/test_arg.py similarity index 100% rename from tests/objects/test_args.py rename to tests/objects/test_arg.py diff --git a/tests/objects/test_attrs.py b/tests/objects/test_attr.py similarity index 100% rename from tests/objects/test_attrs.py rename to tests/objects/test_attr.py diff --git a/tests/objects/test_defs.py b/tests/objects/test_callable.py similarity index 100% rename from tests/objects/test_defs.py rename to tests/objects/test_callable.py diff --git a/tests/objects/test_google.py b/tests/objects/test_google.py index 15109a75..ed44ab5e 100644 --- a/tests/objects/test_google.py +++ b/tests/objects/test_google.py @@ -1,9 +1,9 @@ import ast -from mkapi.objects import Attribute +from mkapi.objects import Attribute, Function, merge_docstring -def test_parse(google): +def test_merge_docstring_attribute(google): name = "module_level_variable2" node = google.get(name) assert isinstance(node, Attribute) @@ -12,3 +12,23 @@ def test_parse(google): assert node.docstring.endswith("by a colon.") assert isinstance(node.type, ast.Name) assert node.type.id == "int" + + +def test_property(google): + cls = google.get("ExampleClass") + attr = cls.get("readonly_property") + assert isinstance(attr, Attribute) + assert attr.docstring + assert attr.docstring.startswith("Properties should be") + assert ast.unparse(attr.type) == "str" # type: ignore + attr = cls.get("readwrite_property") + assert isinstance(attr, Attribute) + assert attr.docstring + assert attr.docstring.endswith("mentioned here.") + assert ast.unparse(attr.type) == "list[str]" # type: ignore + + +def test_merge_docstring(google): + module = google + merge_docstring(module) + assert 0 diff --git a/tests/test_utils.py b/tests/test_utils.py index 2558b009..fea03bd3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,4 @@ -from mkapi.utils import add_admonition, add_fence, find_submodule_names +from mkapi.utils import add_admonition, add_fence, find_submodule_names, get_by_name def test_find_submodule_names(): @@ -48,3 +48,14 @@ def test_add_admonition(): assert markdown == '!!! note "Note"\n abc\n\n def' markdown = add_admonition("Tips", "abc\n\ndef") assert markdown == '!!! tips "Tips"\n abc\n\n def' + + +class A: + def __init__(self, name): + self.name = name + + +def test_get_by_name(): + items = [A("a"), A("b"), A("c")] + assert get_by_name(items, "b").name == "b" # type: ignore + assert get_by_name(items, "x") is None From 862876993c84b7efde8b0cb3f6fd4ce84ddd47cf Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 5 Jan 2024 12:18:30 +0900 Subject: [PATCH 056/148] docstrings.merge --- src/mkapi/docstrings.py | 131 +++++++++++---- src/mkapi/objects.py | 230 +++++++++++++++------------ src/mkapi/utils.py | 13 +- tests/docstrings/test_google.py | 35 ++-- tests/docstrings/test_merge.py | 22 +++ tests/docstrings/test_numpy.py | 27 +++- tests/objects/test_google.py | 2 +- tests/objects/test_object.py | 10 +- tests/test_utils.py | 61 ------- tests_old/core/test_core_node_abc.py | 2 +- tests_old/core/test_core_object.py | 2 +- 11 files changed, 315 insertions(+), 220 deletions(-) create mode 100644 tests/docstrings/test_merge.py delete mode 100644 tests/test_utils.py diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 6b9f04da..c3190824 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -5,7 +5,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from mkapi.utils import add_admonition, add_fence, join_without_first_indent +from mkapi.utils import ( + add_admonition, + add_fence, + get_by_name, + join_without_first_indent, + unique_names, +) if TYPE_CHECKING: from collections.abc import Iterator @@ -20,7 +26,7 @@ class Item: # noqa: D101 description: str def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name})" + return f"{self.__class__.__name__}({self.name}:{self.type})" SPLIT_ITEM_PATTERN = re.compile(r"\n\S") @@ -63,10 +69,21 @@ def split_item(item: str, style: Style) -> tuple[str, str, str]: return _split_item_numpy(lines) -def iter_items(section: str, style: Style) -> Iterator[Item]: - """Yiled a tuple of (name, type, description) of item.""" +def iter_items( + section: str, + style: Style, + section_name: str = "Parameters", +) -> Iterator[Item]: + """Yiled a tuple of (name, type, description) of item. + + If name is 'Raises', the type of [Item] is set by its name. + """ for item in _iter_items(section): - yield Item(*split_item(item, style)) + name, type_, desc = split_item(item, style) + if section_name != "Raises": + yield Item(name, type_, desc) + else: + yield Item(name, name, desc) @dataclass @@ -77,10 +94,7 @@ def __iter__(self) -> Iterator[Item]: return iter(self.items) def get(self, name: str) -> Item | None: # noqa: D102 - for item in self.items: - if item.name == name: - return item - return None + return get_by_name(self.items, name) SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { @@ -133,18 +147,6 @@ def _rename_section(section_name: str) -> str: return section_name -def split_section(section: str, style: Style) -> tuple[str, str]: - """Return section name and its description.""" - lines = section.split("\n") - if len(lines) < 2: # noqa: PLR2004 - return "", section - if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): - return lines[0][:-1], join_without_first_indent(lines[1:]) - if style == "numpy" and re.match(r"^-+?$", lines[1]): - return lines[0], join_without_first_indent(lines[2:]) - return "", section - - def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: """Yield (section name, description) pairs by splitting the whole docstring.""" prev_name, prev_desc = "", "" @@ -166,13 +168,25 @@ def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_desc +def split_section(section: str, style: Style) -> tuple[str, str]: + """Return section name and its description.""" + lines = section.split("\n") + if len(lines) < 2: # noqa: PLR2004 + return "", section + if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): + return lines[0][:-1], join_without_first_indent(lines[1:]) + if style == "numpy" and re.match(r"^-+?$", lines[1]): + return lines[0], join_without_first_indent(lines[2:]) + return "", section + + def iter_sections(doc: str, style: Style) -> Iterator[Section]: """Yield [Section] instance by splitting the whole docstring.""" for name, desc in _iter_sections(doc, style): type_ = desc_ = "" items: list[Item] = [] if name in ["Parameters", "Attributes", "Raises"]: - items = list(iter_items(desc, style)) + items = list(iter_items(desc, style, name)) elif name in ["Returns", "Yields"]: type_, desc_ = split_return(desc, style) elif name in ["Note", "Notes", "Warning", "Warnings"]: @@ -193,9 +207,9 @@ def split_return(section: str, style: Style) -> tuple[str, str]: return "", section -# for mkapi.objects.Attribute.docstring +# for mkapi.objects.Attribute.docstring / property def split_attribute(docstring: str) -> tuple[str, str]: - """Return a tuple of (type, description) for Attribute docstring.""" + """Return a tuple of (type, description) for the Attribute docstring.""" return split_return(docstring, "google") @@ -210,14 +224,73 @@ def __iter__(self) -> Iterator[Section]: return iter(self.sections) def get(self, name: str) -> Section | None: # noqa: D102 - for section in self.sections: - if section.name == name: - return section - return None + return get_by_name(self.sections, name) -def parse_docstring(doc: str, style: Style) -> Docstring: +def parse(doc: str, style: Style) -> Docstring: """Return a [Docstring] instance.""" doc = add_fence(doc) sections = list(iter_sections(doc, style)) return Docstring("", "", "", sections) + + +def merge_items(a: list[Item], b: list[Item]) -> list[Item]: + """Merge two lists of [Item] and return a newly created [Docstring] list.""" + items: list[Item] = [] + for name in unique_names(a, b): + ai, bi = get_by_name(a, name), get_by_name(b, name) + if not ai: + items.append(bi) # type: ignore + elif not bi: + items.append(ai) # type: ignore + else: + name_ = ai.name if ai.name else bi.name + type_ = ai.type if ai.type else bi.type + desc = ai.description if ai.description else bi.description + items.append(Item(name_, type_, desc)) + return items + + +def merge_sections(a: Section, b: Section) -> Section: + """Merge two [Section] instances into one [Section] instance.""" + if a.name != b.name: + raise ValueError + type_ = a.type if a.type else b.type + desc = f"{a.description}\n\n{b.description}".strip() + items = merge_items(a.items, b.items) + return Section(a.name, type_, desc, items) + + +def merge(a: Docstring, b: Docstring) -> Docstring: # noqa: PLR0912, C901 + """Merge two [Docstring] instances into one [Docstring] instance.""" + if not a.sections: + return b + if not b.sections: + return a + names = [name for name in unique_names(a.sections, b.sections) if name] + sections: list[Section] = [] + for ai in a.sections: + if ai.name: + break + sections.append(ai) + for name in names: + ai, bi = get_by_name(a.sections, name), get_by_name(b.sections, name) + if not ai: + sections.append(bi) # type: ignore + elif not bi: + sections.append(ai) # type: ignore + else: + sections.append(merge_sections(ai, bi)) + is_named_section = False + for section in a.sections: + if section.name: + is_named_section = True + elif is_named_section: + sections.append(section) + for section in b.sections: + if not section.name: + sections.append(section) # noqa: PERF401 + name_ = a.name if a.name else b.name + type_ = a.type if a.type else b.type + desc = a.description if a.description else b.description + return Docstring(name_, type_, desc, sections) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 85e255f6..a3f76b80 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from mkapi.docstrings import SECTION_NAMES, parse_docstring, split_attribute +from mkapi import docstrings from mkapi.utils import get_by_name if TYPE_CHECKING: @@ -30,14 +30,14 @@ from collections.abc import Iterator from inspect import _ParameterKind - from mkapi.docstrings import Docstring, Item, Section, Style + from mkapi.docstrings import Docstring, Section, Style type Import_ = ast.Import | ImportFrom type FunctionDef_ = AsyncFunctionDef | FunctionDef type Def = FunctionDef_ | ClassDef type Assign_ = Assign | AnnAssign | TypeAlias -current_module_name: list[str | None] = [None] +CURRENT_MODULE_NAME: list[str | None] = [None] current_docstring_style: list[Style] = ["google"] @@ -47,43 +47,37 @@ class Object: # noqa: D101 name: str docstring: str | None - def __post_init__(self) -> None: - # Set parent module. - self.__dict__["__module_name__"] = current_module_name[0] + def __post_init__(self) -> None: # Set parent module name. + self.__dict__["__module_name__"] = CURRENT_MODULE_NAME[0] def __repr__(self) -> str: - # fullname = self.get_fullname() return f"{self.__class__.__name__}({self.name})" - def get_node(self) -> AST: # noqa: D102 - return self._node - - def unparse(self) -> str: # noqa: D102 - return ast.unparse(self._node) + def get_fullname(self) -> str: # noqa: D102 + if module_name := self.get_module_name(): + return f"{module_name}.{self.name}" + return self.name def get_module_name(self) -> str | None: - """Return the module name if exist.""" + """Return the module name.""" return self.__dict__["__module_name__"] def get_module(self) -> Module | None: - """Return a [Module] instance if exist.""" + """Return a [Module] instance.""" if module_name := self.get_module_name(): return get_module(module_name) return None - def get_source(self, maxline: int | None = None) -> str: - """Return the source code.""" - if (name := self.get_module_name()) and (source := _get_source(name)): - lines = source.split("\n")[self._node.lineno - 1 : self._node.end_lineno] - if maxline: - lines = lines[:maxline] - return "\n".join(lines) - return "" + def get_source(self, maxline: int | None = None) -> str | None: + """Return the source code segment.""" + if (name := self.get_module_name()) and (source := _get_module_source(name)): + start, stop = self._node.lineno - 1, self._node.end_lineno + return "\n".join(source.split("\n")[start:stop][:maxline]) + return None - def get_fullname(self) -> str: # noqa: D102 - if module_name := self.get_module_name(): - return f"{module_name}.{self.name}" - return self.name + def unparse(self) -> str: + """Unparse the AST node and return a string expression.""" + return ast.unparse(self._node) @dataclass @@ -118,7 +112,7 @@ def iter_imports(node: ast.Module) -> Iterator[Import]: @dataclass(repr=False) class Attribute(Object): # noqa: D101 - _node: Assign_ | FunctionDef_ + _node: Assign_ | FunctionDef_ | None # Needs FunctionDef_ for property. type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None @@ -162,7 +156,7 @@ def get_assign_name(node: Assign_) -> str | None: def get_type(node: Assign_) -> ast.expr | None: - """Return annotation.""" + """Return a type annotation of the Assign or TypeAlias AST node.""" if isinstance(node, AnnAssign): return node.annotation if isinstance(node, TypeAlias): @@ -171,13 +165,14 @@ def get_type(node: Assign_) -> ast.expr | None: def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: - """Yield assign nodes.""" + """Yield assign nodes from the Module or Class AST node.""" for assign in iter_assign_nodes(node): if not (name := get_assign_name(assign)): continue type_ = get_type(assign) value = None if isinstance(assign, TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, TypeAlias) else None + # TODO: process in docstings module. attr = Attribute(assign, name, assign.__doc__, type_, value, type_params) _merge_docstring_attribute(attr) yield attr @@ -191,7 +186,7 @@ class Parameter(Object): # noqa: D101 kind: _ParameterKind -ARGS_KIND: dict[_ParameterKind, str] = { +PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { P.POSITIONAL_ONLY: "posonlyargs", # before '/', list P.POSITIONAL_OR_KEYWORD: "args", # normal, list P.VAR_POSITIONAL: "vararg", # *args, arg or None @@ -201,7 +196,7 @@ class Parameter(Object): # noqa: D101 def _iter_parameters(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: - for kind, attr in ARGS_KIND.items(): + for kind, attr in PARAMETER_KIND_DICT.items(): if args := getattr(node.args, attr): it = args if isinstance(args, list) else [args] yield from ((arg, kind) for arg in it) @@ -230,7 +225,7 @@ def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: @dataclass(repr=False) class Raise(Object): # noqa: D101 - _node: ast.Raise + _node: ast.Raise | None type: ast.expr | None # # noqa: A003 def __repr__(self) -> str: @@ -239,7 +234,7 @@ def __repr__(self) -> str: def iter_raises(node: FunctionDef_) -> Iterator[Raise]: - """Yield raise nodes.""" + """Yield [Raise] instances.""" for child in ast.walk(node): if isinstance(child, ast.Raise) and child.exc: yield Raise(child, "", None, child.exc) @@ -252,9 +247,8 @@ class Return(Object): # noqa: D101 def get_return(node: FunctionDef_) -> Return: - """Yield raise nodes.""" - ret = node.returns - return Return(ret, "", None, ret) + """Yield [Return] instances.""" + return Return(node.returns, "", None, node.returns) @dataclass(repr=False) @@ -265,6 +259,9 @@ class Callable(Object): # noqa: D101 type_params: list[ast.type_param] raises: list[Raise] + def get_parameter(self, name: str) -> Parameter | None: # noqa: D102 + return get_by_name(self.parameters, name) + @dataclass(repr=False) class Function(Callable): # noqa: D101 @@ -275,6 +272,9 @@ def get_node(self) -> FunctionDef_: # noqa: D102 return self._node +type ClassMember = Parameter | Attribute | Class | Function + + @dataclass(repr=False) class Class(Callable): # noqa: D101 _node: ClassDef @@ -283,11 +283,24 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] - def get(self, name: str) -> Attribute | Class | Function | None: # noqa: D102 - for attr in ["attributes", "classes", "functions"]: - for obj in getattr(self, attr): - if obj.name == name: - return obj + def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 + return get_by_name(self.attributes, name) + + def get_class(self, name: str) -> Class | None: # noqa: D102 + return get_by_name(self.classes, name) + + def get_function(self, name: str) -> Function | None: # noqa: D102 + return get_by_name(self.functions, name) + + def get(self, name: str) -> ClassMember | None: # noqa: D102 + if obj := self.get_parameter(name): + return obj + if obj := self.get_attribute(name): + return obj + if obj := self.get_class(name): + return obj + if obj := self.get_function(name): + return obj return None @@ -337,6 +350,9 @@ def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Functi return classes, functions +type ModuleMember = Import | Attribute | Class | Function + + @dataclass(repr=False) class Module(Object): # noqa: D101 docstring: str | Docstring | None @@ -346,52 +362,44 @@ class Module(Object): # noqa: D101 functions: list[Function] source: str - def get(self, name: str) -> Import | Attribute | Class | Function | None: # noqa: D102 - for attr in ["imports", "attributes", "classes", "functions"]: - for obj in getattr(self, attr): - if obj.name == name: - return obj - return None + def get_import(self, name: str) -> Import | None: # noqa: D102 + return get_by_name(self.imports, name) - def get_fullname(self) -> str: # noqa: D102 - return self.name + def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 + return get_by_name(self.attributes, name) - def get_source(self) -> str: - """Return the source code.""" - return _get_source(self.name) if self.name else "" + def get_class(self, name: str) -> Class | None: # noqa: D102 + return get_by_name(self.classes, name) + def get_function(self, name: str) -> Function | None: # noqa: D102 + return get_by_name(self.functions, name) -cache_module_node: dict[str, tuple[float, ast.Module | None, str]] = {} -cache_module: dict[str, Module | None] = {} + def get(self, name: str) -> ModuleMember | None: # noqa: D102 + if obj := self.get_import(name): + return obj + if obj := self.get_attribute(name): + return obj + if obj := self.get_class(name): + return obj + if obj := self.get_function(name): + return obj + return None + def get_fullname(self) -> str: + """Return the fullname.""" + return self.name -def get_module(name: str) -> Module | None: - """Return a [Module] instance by name.""" - if name in cache_module: - return cache_module[name] - if node := _get_module_node(name): - current_module_name[0] = name - module = _get_module_from_node(node) - current_module_name[0] = None - module.name = name - module.source = _get_source(name) - cache_module[name] = module - return module - cache_module[name] = None - return None + def get_source(self) -> str: + """Return the source code.""" + return _get_module_source(self.name) if self.name else "" -def _get_module_from_node(node: ast.Module) -> Module: - """Return a [Module] instance from [ast.Module] node.""" - docstring = ast.get_docstring(node) - imports = list(iter_imports(node)) - attrs = list(iter_attributes(node)) - classes, functions = get_callables(node) - return Module(node, "", docstring, imports, attrs, classes, functions, "") +CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module | None, str]] = {} +CACHE_MODULE: dict[str, Module | None] = {} def _get_module_node(name: str) -> ast.Module | None: - """Return a [ast.Module] node by name.""" + """Return a [ast.Module] node by the name.""" try: spec = find_spec(name) except ModuleNotFoundError: @@ -402,25 +410,58 @@ def _get_module_node(name: str) -> ast.Module | None: if not path.exists(): # for builtin, frozen return None mtime = path.stat().st_mtime - if name in cache_module_node and mtime == cache_module_node[name][0]: - return cache_module_node[name][1] + if name in CACHE_MODULE_NODE and mtime == CACHE_MODULE_NODE[name][0]: + return CACHE_MODULE_NODE[name][1] with path.open(encoding="utf-8") as f: source = f.read() node = ast.parse(source) - cache_module_node[name] = (mtime, node, source) - if name in cache_module: - del cache_module[name] + CACHE_MODULE_NODE[name] = (mtime, node, source) + if name in CACHE_MODULE: + del CACHE_MODULE[name] return node -def _get_source(name: str) -> str: - if name in cache_module_node: - return cache_module_node[name][2] +def _get_module_source(name: str) -> str: + if name in CACHE_MODULE_NODE: + return CACHE_MODULE_NODE[name][2] return "" -def get_object(fullname: str) -> Module | Class | Function | Attribute | None: - """Return a [Object] instance by name.""" +def _get_module_from_node(node: ast.Module) -> Module: + """Return a [Module] instance from the [ast.Module] node.""" + docstring = ast.get_docstring(node) + imports = list(iter_imports(node)) + attrs = list(iter_attributes(node)) + classes, functions = get_callables(node) + return Module(node, "", docstring, imports, attrs, classes, functions, "") + + +def get_module(name: str) -> Module | None: + """Return a [Module] instance by the name.""" + if name in CACHE_MODULE: + return CACHE_MODULE[name] + if node := _get_module_node(name): + CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. + module = _get_module_from_node(node) + CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. + module.name = name + module.source = _get_module_source(name) # Set from a global cache. + CACHE_MODULE[name] = module + return module + CACHE_MODULE[name] = None + return None + + +def get_object_from_module(name: str, module: Module) -> Module | ModuleMember | None: + """Return a [Object] instance by the name from a [Module] instance.""" + obj = module.get(name) + if isinstance(obj, Import): + return get_object(obj.fullname) + return obj + + +def get_object(fullname: str) -> Module | ModuleMember | None: + """Return a [Object] instance by the fullname.""" if module := get_module(fullname): return module if "." not in fullname: @@ -431,17 +472,6 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: return get_object_from_module(name, module) -def get_object_from_module( - name: str, - module: Module, -) -> Module | Class | Function | Attribute | None: - """Return a [Object] instance by name from [Module].""" - obj = module.get(name) - if isinstance(obj, Import): - return get_object(obj.fullname) - return obj - - SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") @@ -464,7 +494,7 @@ def _to_expr(name: str) -> ast.expr: def _merge_docstring_attribute(obj: Attribute) -> None: if doc := obj.docstring: - type_, desc = split_attribute(doc) + type_, desc = docstrings.split_attribute(doc) if not obj.type and type_: obj.type = _to_expr(type_) obj.docstring = desc @@ -491,7 +521,7 @@ def _move_property(obj: Class) -> None: def _get_style(doc: str) -> Style: - for names in SECTION_NAMES: + for names in docstrings.SECTION_NAMES: for name in names: if f"\n\n{name}\n----" in doc: current_docstring_style[0] = "numpy" @@ -544,7 +574,7 @@ def merge_docstring(obj: Module | Class | Function) -> None: if not (doc := obj.docstring) or not isinstance(doc, str): return style = _get_style(doc) - docstring = parse_docstring(doc, style) + docstring = docstrings.parse(doc, style) for section in docstring: if section.name == "Attributes" and isinstance(obj, Module | Class): _merge_docstring_attributes(obj, section) diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 28f4f7d9..bb233040 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations import re +from dataclasses import fields from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING @@ -146,8 +147,16 @@ def add_admonition(name: str, markdown: str) -> str: return "\n".join(lines) -def get_by_name[T](items: list[T], name: str) -> T | None: # noqa: D103 +def get_by_name[T](items: list[T], name: str, attr: str = "name") -> T | None: # noqa: D103 for item in items: - if getattr(item, "name", None) == name: + if getattr(item, attr, None) == name: return item return None + + +def unique_names(a: list, b: list, attr: str = "name") -> list[str]: # noqa: D103 + names = [getattr(x, attr) for x in a] + for x in b: + if (name := getattr(x, attr)) not in names: + names.append(name) + return names diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index ecc4b232..23901cfd 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -2,12 +2,12 @@ _iter_items, _iter_sections, iter_items, - parse_docstring, + parse, split_item, split_return, split_section, ) -from mkapi.objects import Module, get_object_from_module +from mkapi.objects import Module def test_split_section(): @@ -55,7 +55,7 @@ def test_iter_sections(google: Module): def test_iter_items(google: Module): - doc = google.get("module_level_function").docstring + doc = google.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] items = list(_iter_items(section)) @@ -72,8 +72,8 @@ def test_iter_items(google: Module): assert items[0].startswith("module_") -def test_split_item(google): - doc = google.get("module_level_function").docstring +def test_split_item(google: Module): + doc = google.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) sections = list(_iter_sections(doc, "google")) section = sections[1][1] @@ -92,8 +92,8 @@ def test_split_item(google): assert x[2].endswith("the interface.") -def test_iter_items_class(google): - doc = google.get("ExampleClass").docstring +def test_iter_items_class(google: Module): + doc = google.get("ExampleClass").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) @@ -103,7 +103,7 @@ def test_iter_items_class(google): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].description == "Description of `attr2`." - doc = google.get("ExampleClass").get("__init__").docstring + doc = google.get("ExampleClass").get("__init__").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) @@ -113,8 +113,8 @@ def test_iter_items_class(google): assert x[1].description == "Description of `param2`. Multiple\nlines are supported." -def test_split_return(google): - doc = google.get("module_level_function").docstring +def test_split_return(google: Module): + doc = google.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = split_return(section, "google") @@ -123,6 +123,17 @@ def test_split_return(google): assert x[1].endswith(" }") -def test_repr(google): - r = repr(parse_docstring(google.docstring, "google")) +def test_repr(google: Module): + r = repr(parse(google.docstring, "google")) # type: ignore assert r == "Docstring(num_sections=6)" + + +def test_iter_items_raises(google: Module): + doc = google.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + name, section = list(_iter_sections(doc, "google"))[3] + assert name == "Raises" + items = list(iter_items(section, "google", name)) + assert len(items) == 2 + assert items[0].type == items[0].name == "AttributeError" + assert items[1].type == items[1].name == "ValueError" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py new file mode 100644 index 00000000..fd755c5a --- /dev/null +++ b/tests/docstrings/test_merge.py @@ -0,0 +1,22 @@ +from mkapi.docstrings import Item, merge, merge_items, parse + + +def test_merge_items(): + a = [Item("a", "", "item a"), Item("b", "int", "item b")] + b = [Item("a", "str", "item A"), Item("c", "list", "item c")] + c = merge_items(a, b) + assert c[0].name == "a" + assert c[0].type == "str" + assert c[0].description == "item a" + assert c[1].name == "b" + assert c[1].type == "int" + assert c[2].name == "c" + assert c[2].type == "list" + + +def test_merge(google): + a = parse(google.get("ExampleClass").docstring, "google") + b = parse(google.get("ExampleClass").get("__init__").docstring, "google") + doc = merge(a, b) + assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] + doc.sections[-1].description.endswith("with it.") diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index 45359481..bc2d058c 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -53,7 +53,7 @@ def test_iter_sections(numpy: Module): def test_iter_items(numpy: Module): - doc = numpy.get("module_level_function").docstring + doc = numpy.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] items = list(_iter_items(section)) @@ -70,8 +70,8 @@ def test_iter_items(numpy: Module): assert items[0].startswith("module_") -def test_split_item(numpy): - doc = numpy.get("module_level_function").docstring +def test_split_item(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) sections = list(_iter_sections(doc, "numpy")) items = list(_iter_items(sections[1][1])) @@ -88,8 +88,8 @@ def test_split_item(numpy): assert x[2].endswith("the interface.") -def test_iter_items_class(numpy): - doc = numpy.get("ExampleClass").docstring +def test_iter_items_class(numpy: Module): + doc = numpy.get("ExampleClass").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) @@ -99,7 +99,7 @@ def test_iter_items_class(numpy): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].description == "Description of `attr2`." - doc = numpy.get("ExampleClass").get("__init__").docstring + doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) @@ -109,11 +109,22 @@ def test_iter_items_class(numpy): assert x[1].description == "Description of `param2`. Multiple\nlines are supported." -def test_get_return(numpy): - doc = numpy.get("module_level_function").docstring +def test_get_return(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = split_return(section, "numpy") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") + + +def test_iter_items_raises(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + name, section = list(_iter_sections(doc, "numpy"))[3] + assert name == "Raises" + items = list(iter_items(section, "numpy", name)) + assert len(items) == 2 + assert items[0].type == items[0].name == "AttributeError" + assert items[1].type == items[1].name == "ValueError" diff --git a/tests/objects/test_google.py b/tests/objects/test_google.py index ed44ab5e..629d6594 100644 --- a/tests/objects/test_google.py +++ b/tests/objects/test_google.py @@ -31,4 +31,4 @@ def test_property(google): def test_merge_docstring(google): module = google merge_docstring(module) - assert 0 + # assert 0 diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 3a9ecfa5..faeb9949 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -67,11 +67,11 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): assert _get_module_node("xxx") is None assert get_module("xxx") is None - assert mkapi.objects.cache_module["xxx"] is None - assert "xxx" not in mkapi.objects.cache_module_node + assert mkapi.objects.CACHE_MODULE["xxx"] is None + assert "xxx" not in mkapi.objects.CACHE_MODULE_NODE assert get_module("markdown") is not None - assert "markdown" in mkapi.objects.cache_module - assert "markdown" in mkapi.objects.cache_module_node + assert "markdown" in mkapi.objects.CACHE_MODULE + assert "markdown" in mkapi.objects.CACHE_MODULE_NODE def test_repr(): @@ -85,7 +85,7 @@ def test_repr(): assert repr(obj) == "Class(BasePlugin)" -def test_get_source(): +def test_get_module_source(): module = get_module("mkdocs.structure.files") assert module assert "class File" in module.source diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index fea03bd3..00000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,61 +0,0 @@ -from mkapi.utils import add_admonition, add_fence, find_submodule_names, get_by_name - - -def test_find_submodule_names(): - names = find_submodule_names("mkdocs") - assert "mkdocs.commands" in names - assert "mkdocs.plugins" in names - - -source = """ABC - ->>> 1 + 2 -3 - -DEF - ->>> 3 + 4 -7 - -GHI -""" - -output = """ABC - -~~~python ->>> 1 + 2 -3 -~~~ - -DEF - -~~~python ->>> 3 + 4 -7 -~~~ - -GHI""" - - -def test_add_fence(): - assert add_fence(source) == output - - -def test_add_admonition(): - markdown = add_admonition("Warnings", "abc\n\ndef") - assert markdown == '!!! warning "Warnings"\n abc\n\n def' - markdown = add_admonition("Note", "abc\n\ndef") - assert markdown == '!!! note "Note"\n abc\n\n def' - markdown = add_admonition("Tips", "abc\n\ndef") - assert markdown == '!!! tips "Tips"\n abc\n\n def' - - -class A: - def __init__(self, name): - self.name = name - - -def test_get_by_name(): - items = [A("a"), A("b"), A("c")] - assert get_by_name(items, "b").name == "b" # type: ignore - assert get_by_name(items, "x") is None diff --git a/tests_old/core/test_core_node_abc.py b/tests_old/core/test_core_node_abc.py index 3ae2934c..5f5dee6e 100644 --- a/tests_old/core/test_core_node_abc.py +++ b/tests_old/core/test_core_node_abc.py @@ -48,7 +48,7 @@ def abstract_readwrite_property(self, val): pass -def test_get_sourcefiles(): +def test_get_module_sourcefiles(): files = get_sourcefiles(C) assert len(files) == 1 diff --git a/tests_old/core/test_core_object.py b/tests_old/core/test_core_object.py index 9acba62b..bbbb0be5 100644 --- a/tests_old/core/test_core_object.py +++ b/tests_old/core/test_core_object.py @@ -19,7 +19,7 @@ def test_get_origin(): assert org is Node -def test_get_sourcefile_and_lineno(): +def test_get_module_sourcefile_and_lineno(): sourcefile, _ = get_sourcefile_and_lineno(Node) assert sourcefile.endswith("node.py") From 525ab75a4b1b70531e489ee40bb99a712b9fb1a0 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 5 Jan 2024 18:01:27 +0900 Subject: [PATCH 057/148] postprocess --- pyproject.toml | 2 + src/mkapi/docstrings.py | 4 +- src/mkapi/objects.py | 142 +++++++++++++++++++---------------- src/mkapi/utils.py | 8 +- tests/conftest.py | 2 + tests/objects/test_google.py | 34 --------- tests/objects/test_merge.py | 128 +++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 101 deletions(-) delete mode 100644 tests/objects/test_google.py create mode 100644 tests/objects/test_merge.py diff --git a/pyproject.toml b/pyproject.toml index 8110a9e7..70c8eed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,8 @@ ignore = [ "ERA001", "N812", "PGH003", + "TRY003", + "EM102", ] [tool.ruff.extend-per-file-ignores] diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index c3190824..782adf91 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -261,8 +261,10 @@ def merge_sections(a: Section, b: Section) -> Section: return Section(a.name, type_, desc, items) -def merge(a: Docstring, b: Docstring) -> Docstring: # noqa: PLR0912, C901 +def merge(a: Docstring | None, b: Docstring | None) -> Docstring: # noqa: PLR0912, C901 """Merge two [Docstring] instances into one [Docstring] instance.""" + a = a or Docstring("", "", "", []) + b = b or Docstring("", "", "", []) if not a.sections: return b if not b.sections: diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index a3f76b80..c6bb34d8 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -23,14 +23,14 @@ from typing import TYPE_CHECKING from mkapi import docstrings -from mkapi.utils import get_by_name +from mkapi.utils import del_by_name, get_by_name, unique_names if TYPE_CHECKING: from ast import AST from collections.abc import Iterator from inspect import _ParameterKind - from mkapi.docstrings import Docstring, Section, Style + from mkapi.docstrings import Docstring, Item, Section, Style type Import_ = ast.Import | ImportFrom type FunctionDef_ = AsyncFunctionDef | FunctionDef @@ -53,11 +53,6 @@ def __post_init__(self) -> None: # Set parent module name. def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" - def get_fullname(self) -> str: # noqa: D102 - if module_name := self.get_module_name(): - return f"{module_name}.{self.name}" - return self.name - def get_module_name(self) -> str | None: """Return the module name.""" return self.__dict__["__module_name__"] @@ -87,9 +82,6 @@ class Import(Object): # noqa: D101 fullname: str from_: str | None - def get_fullname(self) -> str: # noqa: D102 - return self.fullname - def iter_import_nodes(node: AST) -> Iterator[Import_]: """Yield import nodes.""" @@ -172,18 +164,17 @@ def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: type_ = get_type(assign) value = None if isinstance(assign, TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, TypeAlias) else None - # TODO: process in docstings module. attr = Attribute(assign, name, assign.__doc__, type_, value, type_params) - _merge_docstring_attribute(attr) + _merge_attribute_docstring(attr) yield attr @dataclass(repr=False) class Parameter(Object): # noqa: D101 - _node: ast.arg + _node: ast.arg | None type: ast.expr | None # # noqa: A003 default: ast.expr | None - kind: _ParameterKind + kind: _ParameterKind | None PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { @@ -247,7 +238,7 @@ class Return(Object): # noqa: D101 def get_return(node: FunctionDef_) -> Return: - """Yield [Return] instances.""" + """Return a [Return] instance.""" return Return(node.returns, "", None, node.returns) @@ -385,10 +376,6 @@ def get(self, name: str) -> ModuleMember | None: # noqa: D102 return obj return None - def get_fullname(self) -> str: - """Return the fullname.""" - return self.name - def get_source(self) -> str: """Return the source code.""" return _get_module_source(self.name) if self.name else "" @@ -412,7 +399,7 @@ def _get_module_node(name: str) -> ast.Module | None: mtime = path.stat().st_mtime if name in CACHE_MODULE_NODE and mtime == CACHE_MODULE_NODE[name][0]: return CACHE_MODULE_NODE[name][1] - with path.open(encoding="utf-8") as f: + with path.open("r", encoding="utf-8") as f: source = f.read() node = ast.parse(source) CACHE_MODULE_NODE[name] = (mtime, node, source) @@ -446,6 +433,7 @@ def get_module(name: str) -> Module | None: CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. module.name = name module.source = _get_module_source(name) # Set from a global cache. + _postprocess(module) CACHE_MODULE[name] = module return module CACHE_MODULE[name] = None @@ -492,7 +480,7 @@ def _to_expr(name: str) -> ast.expr: return Constant(value=name) -def _merge_docstring_attribute(obj: Attribute) -> None: +def _merge_attribute_docstring(obj: Attribute) -> None: if doc := obj.docstring: type_, desc = docstrings.split_attribute(doc) if not obj.type and type_: @@ -515,7 +503,7 @@ def _move_property(obj: Class) -> None: type_ = func.returns.type type_params = func.type_params attr = Attribute(node, func.name, doc, type_, None, type_params) - _merge_docstring_attribute(attr) + _merge_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs @@ -530,45 +518,39 @@ def _get_style(doc: str) -> Style: return "google" -def _merge_docstring_attributes(obj: Module | Class, section: Section) -> None: - print("----------------Attributes----------------") - names = set([x.name for x in section.items] + [x.name for x in obj.attributes]) - print(names) - attrs: list[Attribute] = [] - for name in names: - if not (attr := get_by_name(obj.attributes, name)): - attr = Attribute(None, name, None, None, None, []) # type: ignore - attrs.append(attr) - if not (item := get_by_name(section.items, name)): - continue - item # TODO - obj.attributes = attrs - - -def _merge_docstring_parameters(obj: Class | Function, section: Section) -> None: - print("----------------Parameters----------------") - names = set([x.name for x in section.items] + [x.name for x in obj.parameters]) - print(names) - for item in section: - print(item) - for attr in obj.parameters: - print(attr) +def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: + if not obj.type and item.type: + obj.type = _to_expr(item.type) + obj.docstring = item.description # Does item.description win? -def _merge_docstring_raises(obj: Class | Function, section: Section) -> None: - print("----------------Raises----------------") - # Fix raises.name - names = set([x.name for x in section.items] + [x.name for x in obj.raises]) - print(names) +def _new( + cls: type[Attribute | Parameter | Raise], + name: str, +) -> Attribute | Parameter | Raise: + if cls is Attribute: + return Attribute(None, name, None, None, None, []) + if cls is Parameter: + return Parameter(None, name, None, None, None, None) + if cls is Raise: + return Raise(None, name, None, None) + raise NotImplementedError -def _merge_docstring_returns(obj: Function, section: Section) -> None: - print("----------------Returns----------------") - print(section.description) - print(obj.returns) +def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: + names = unique_names(attrs, items) + attrs_ = [] + for name in names: + if not (attr := get_by_name(attrs, name)): + attr = _new(cls, name) + attrs_.append(attr) + if not (item := get_by_name(items, name)): + continue + _merge_item(attr, item) # type: ignore + return attrs_ -def merge_docstring(obj: Module | Class | Function) -> None: +def _merge_docstring(obj: Module | Class | Function) -> None: """Merge [Object] and [Docstring].""" sections: list[Section] = [] if not (doc := obj.docstring) or not isinstance(doc, str): @@ -577,24 +559,54 @@ def merge_docstring(obj: Module | Class | Function) -> None: docstring = docstrings.parse(doc, style) for section in docstring: if section.name == "Attributes" and isinstance(obj, Module | Class): - _merge_docstring_attributes(obj, section) + obj.attributes = _merge_items(Attribute, obj.attributes, section.items) elif section.name == "Parameters" and isinstance(obj, Class | Function): - _merge_docstring_parameters(obj, section) + obj.parameters = _merge_items(Parameter, obj.parameters, section.items) elif section.name == "Raises" and isinstance(obj, Class | Function): - _merge_docstring_raises(obj, section) + obj.raises = _merge_items(Raise, obj.raises, section.items) elif section.name in ["Returns", "Yields"] and isinstance(obj, Function): - _merge_docstring_returns(obj, section) + _merge_item(obj.returns, section) + obj.returns.name = section.name else: sections.append(section) docstring.sections = sections obj.docstring = docstring -# def _resolve_bases(obj: Class) -> None: -# obj.bases = [obj.get_module_name()] +DEBUG_FOR_PYTEST = False # for pytest. + + +def _postprocess(obj: Module | Class) -> None: + if DEBUG_FOR_PYTEST: + return + for function in obj.functions: + _merge_docstring(function) + if isinstance(obj, Class): + del function.parameters[0] # Delete 'self' TODO: static method. + for cls in obj.classes: + _postprocess(cls) + _postprocess_class(cls) + _merge_docstring(obj) + + +_ATTRIBUTE_ORDER_DICT = { + type(None): 0, + AnnAssign: 1, + Assign: 2, + FunctionDef: 3, + AsyncFunctionDef: 4, +} + + +def _attribute_order(attr: Attribute) -> int: + return _ATTRIBUTE_ORDER_DICT.get(type(attr._node), 10) # type: ignore # noqa: SLF001 -# def merge_docstring(obj: Object, style: Style) -> None: -# if isinstance(obj, Attribute): -# return _merge_docstring_attribute(obj) -# if isinstance(obj, +def _postprocess_class(cls: Class) -> None: + if init := cls.get_function("__init__"): + cls.parameters = init.parameters + cls.raises = init.raises + cls.docstring = docstrings.merge(cls.docstring, init.docstring) # type: ignore + cls.attributes.sort(key=_attribute_order) + del_by_name(cls.functions, "__init__") + # TODO: dataclass, bases diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index bb233040..f9aa2ba1 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import re -from dataclasses import fields from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING @@ -154,6 +153,13 @@ def get_by_name[T](items: list[T], name: str, attr: str = "name") -> T | None: return None +def del_by_name[T](items: list[T], name: str, attr: str = "name") -> None: # noqa: D103 + for k, item in enumerate(items): + if getattr(item, attr, None) == name: + del items[k] + return + + def unique_names(a: list, b: list, attr: str = "name") -> list[str]: # noqa: D103 names = [getattr(x, attr) for x in a] for x in b: diff --git a/tests/conftest.py b/tests/conftest.py index 55dd0fe9..c5dfd0c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,13 @@ import pytest +import mkapi.objects from mkapi.objects import get_module @pytest.fixture(scope="module") def google(): + mkapi.objects.DEBUG_FOR_PYTEST = True path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) diff --git a/tests/objects/test_google.py b/tests/objects/test_google.py deleted file mode 100644 index 629d6594..00000000 --- a/tests/objects/test_google.py +++ /dev/null @@ -1,34 +0,0 @@ -import ast - -from mkapi.objects import Attribute, Function, merge_docstring - - -def test_merge_docstring_attribute(google): - name = "module_level_variable2" - node = google.get(name) - assert isinstance(node, Attribute) - assert isinstance(node.docstring, str) - assert node.docstring.startswith("Module level") - assert node.docstring.endswith("by a colon.") - assert isinstance(node.type, ast.Name) - assert node.type.id == "int" - - -def test_property(google): - cls = google.get("ExampleClass") - attr = cls.get("readonly_property") - assert isinstance(attr, Attribute) - assert attr.docstring - assert attr.docstring.startswith("Properties should be") - assert ast.unparse(attr.type) == "str" # type: ignore - attr = cls.get("readwrite_property") - assert isinstance(attr, Attribute) - assert attr.docstring - assert attr.docstring.endswith("mentioned here.") - assert ast.unparse(attr.type) == "list[str]" # type: ignore - - -def test_merge_docstring(google): - module = google - merge_docstring(module) - # assert 0 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py new file mode 100644 index 00000000..4b973b5d --- /dev/null +++ b/tests/objects/test_merge.py @@ -0,0 +1,128 @@ +import ast + +import pytest + +import mkapi.objects +from mkapi.docstrings import Docstring +from mkapi.objects import ( + CACHE_MODULE, + CACHE_MODULE_NODE, + Attribute, + Class, + Function, + Module, + Parameter, + get_module, +) +from mkapi.utils import get_by_name + + +def test_merge_attribute_docstring(google): + name = "module_level_variable2" + node = google.get(name) + assert isinstance(node, Attribute) + assert isinstance(node.docstring, str) + assert node.docstring.startswith("Module level") + assert node.docstring.endswith("by a colon.") + assert isinstance(node.type, ast.Name) + assert node.type.id == "int" + + +def test_move_property_to_attributes(google): + cls = google.get("ExampleClass") + attr = cls.get("readonly_property") + assert isinstance(attr, Attribute) + assert attr.docstring + assert attr.docstring.startswith("Properties should be") + assert ast.unparse(attr.type) == "str" # type: ignore + attr = cls.get("readwrite_property") + assert isinstance(attr, Attribute) + assert attr.docstring + assert attr.docstring.endswith("mentioned here.") + assert ast.unparse(attr.type) == "list[str]" # type: ignore + + +@pytest.fixture() +def module(): + name = "examples.styles.example_google" + if name in CACHE_MODULE_NODE: + del CACHE_MODULE_NODE[name] + if name in CACHE_MODULE: + del CACHE_MODULE[name] + mkapi.objects.DEBUG_FOR_PYTEST = False + yield get_module(name) + mkapi.objects.DEBUG_FOR_PYTEST = True + + +def test_merge_module_attrs(module: Module): + x = module.get_attribute("module_level_variable1") + assert isinstance(x, Attribute) + assert x.docstring + assert x.docstring.startswith("Module level") + assert isinstance(x.type, ast.Name) + assert isinstance(x.default, ast.Constant) + assert isinstance(module.docstring, Docstring) + assert len(module.docstring.sections) == 5 + assert get_by_name(module.docstring.sections, "Attributes") is None + + +def test_merge_function_args(module: Module): + f = module.get_function("function_with_types_in_docstring") + assert isinstance(f, Function) + assert isinstance(f.docstring, Docstring) + assert len(f.docstring.sections) == 2 + p = get_by_name(f.parameters, "param1") + assert isinstance(p, Parameter) + assert isinstance(p.type, ast.Name) + assert isinstance(p.docstring, str) + assert p.docstring.startswith("The first") + + +def test_merge_function_returns(module: Module): + f = module.get_function("function_with_types_in_docstring") + assert isinstance(f, Function) + r = f.returns + assert r.name == "Returns" + assert isinstance(r.type, ast.Name) + assert ast.unparse(r.type) == "bool" + assert isinstance(r.docstring, str) + assert r.docstring.startswith("The return") + + +def test_merge_function_pep484(module: Module): + f = module.get_function("function_with_pep484_type_annotations") + x = f.get_parameter("param1") # type: ignore + assert x.docstring.startswith("The first") # type: ignore + + +def test_merge_generator(module: Module): + g = module.get_function("example_generator") + assert g.returns.name == "Yields" # type: ignore + + +def test_postprocess_class(module: Module): + c = module.get_class("ExampleError") + assert isinstance(c, Class) + assert len(c.parameters) == 2 + assert len(c.docstring.sections) == 2 # type: ignore + assert not c.functions + c = module.get_class("ExampleClass") + assert isinstance(c, Class) + assert len(c.parameters) == 3 + assert len(c.docstring.sections) == 3 # type: ignore + assert ast.unparse(c.parameters[2].type) == "list[str]" # type: ignore + assert c.attributes[0].name == "attr1" + f = c.get_function("example_method") + assert len(f.parameters) == 2 # type: ignore + + +def test_postprocess_class_pep526(module: Module): + c = module.get_class("ExamplePEP526Class") + assert isinstance(c, Class) + assert len(c.parameters) == 0 + assert len(c.docstring.sections) == 1 # type: ignore + assert not c.functions + assert c.attributes + assert c.attributes[0].name == "attr1" + assert isinstance(c.attributes[0].type, ast.Name) + assert c.attributes[0].docstring == "Description of `attr1`." From 5426e34bc10bdd4a3146b8b11140fca86ac24898 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 5 Jan 2024 19:53:29 +0900 Subject: [PATCH 058/148] CI --- .github/workflows/ci.yml | 4 +- pyproject.toml | 18 ++---- src/mkapi/docstrings.py | 108 +++++++++++++++----------------- src/mkapi/objects.py | 2 +- tests/docstrings/test_google.py | 6 +- tests/docstrings/test_merge.py | 7 ++- tests/docstrings/test_numpy.py | 4 +- 7 files changed, 66 insertions(+), 83 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7a2c5a5..d38d5095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest] # , windows-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.12'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 70c8eed5..6a98cc72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ exclude = ["/.github", "/docs"] packages = ["src/mkapi"] [[tool.hatch.envs.all.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +python = ["3.12"] [tool.hatch.envs.default] dependencies = ["pytest-cov", "pymdown-extensions"] @@ -74,7 +74,7 @@ serve = "mkdocs serve --dev-addr localhost:8000 {args}" deploy = "mkdocs gh-deploy --force" [tool.ruff] -target-version = "py311" +target-version = "py312" line-length = 88 select = ["ALL"] ignore = [ @@ -91,21 +91,11 @@ ignore = [ "TRY003", "EM102", ] +exclude = ["examples"] + [tool.ruff.extend-per-file-ignores] "tests/*.py" = ["ANN", "D", "S101", "INP001", "T201", "PLR2004", "PGH003"] -"examples/*.py" = [ - "ANN", - "ARG001", - "D", - "E741", - "ERA001", - "INP001", - "PLR0913", - "PLR2004", - "S101", - "T201", -] [tool.ruff.lint] unfixable = [ diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 782adf91..c01bf7ee 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -147,6 +147,18 @@ def _rename_section(section_name: str) -> str: return section_name +def split_section(section: str, style: Style) -> tuple[str, str]: + """Return section name and its description.""" + lines = section.split("\n") + if len(lines) < 2: # noqa: PLR2004 + return "", section + if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): + return lines[0][:-1], join_without_first_indent(lines[1:]) + if style == "numpy" and re.match(r"^-+?$", lines[1]): + return lines[0], join_without_first_indent(lines[2:]) + return "", section + + def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: """Yield (section name, description) pairs by splitting the whole docstring.""" prev_name, prev_desc = "", "" @@ -168,16 +180,15 @@ def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_desc -def split_section(section: str, style: Style) -> tuple[str, str]: - """Return section name and its description.""" - lines = section.split("\n") - if len(lines) < 2: # noqa: PLR2004 - return "", section - if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): - return lines[0][:-1], join_without_first_indent(lines[1:]) - if style == "numpy" and re.match(r"^-+?$", lines[1]): - return lines[0], join_without_first_indent(lines[2:]) - return "", section +def split_without_name(desc: str, style: Style) -> tuple[str, str]: + """Return a tuple of (type, description) for Returns or Yields section.""" + lines = desc.split("\n") + if style == "google" and ":" in lines[0]: + type_, desc_ = lines[0].split(":", maxsplit=1) + return type_.strip(), "\n".join([desc_.strip(), *lines[1:]]) + if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): + return lines[0], join_without_first_indent(lines[1:]) + return "", desc def iter_sections(doc: str, style: Style) -> Iterator[Section]: @@ -188,7 +199,7 @@ def iter_sections(doc: str, style: Style) -> Iterator[Section]: if name in ["Parameters", "Attributes", "Raises"]: items = list(iter_items(desc, style, name)) elif name in ["Returns", "Yields"]: - type_, desc_ = split_return(desc, style) + type_, desc_ = split_without_name(desc, style) elif name in ["Note", "Notes", "Warning", "Warnings"]: desc_ = add_admonition(name, desc) else: @@ -196,23 +207,6 @@ def iter_sections(doc: str, style: Style) -> Iterator[Section]: yield Section(name, type_, desc_, items) -def split_return(section: str, style: Style) -> tuple[str, str]: - """Return a tuple of (type, description) for Returns and Yields section.""" - lines = section.split("\n") - if style == "google" and ":" in lines[0]: - type_, desc = lines[0].split(":", maxsplit=1) - return type_.strip(), "\n".join([desc.strip(), *lines[1:]]) - if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): - return lines[0], join_without_first_indent(lines[1:]) - return "", section - - -# for mkapi.objects.Attribute.docstring / property -def split_attribute(docstring: str) -> tuple[str, str]: - """Return a tuple of (type, description) for the Attribute docstring.""" - return split_return(docstring, "google") - - @dataclass(repr=False) class Docstring(Item): # noqa: D101 sections: list[Section] @@ -234,21 +228,19 @@ def parse(doc: str, style: Style) -> Docstring: return Docstring("", "", "", sections) -def merge_items(a: list[Item], b: list[Item]) -> list[Item]: - """Merge two lists of [Item] and return a newly created [Docstring] list.""" - items: list[Item] = [] +def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: + """Yield merged [Item] instances from two list of [Item].""" for name in unique_names(a, b): ai, bi = get_by_name(a, name), get_by_name(b, name) - if not ai: - items.append(bi) # type: ignore - elif not bi: - items.append(ai) # type: ignore - else: + if ai and not bi: + yield ai + elif not ai and bi: + yield bi + elif ai and bi: name_ = ai.name if ai.name else bi.name type_ = ai.type if ai.type else bi.type desc = ai.description if ai.description else bi.description - items.append(Item(name_, type_, desc)) - return items + yield Item(name_, type_, desc) def merge_sections(a: Section, b: Section) -> Section: @@ -257,41 +249,41 @@ def merge_sections(a: Section, b: Section) -> Section: raise ValueError type_ = a.type if a.type else b.type desc = f"{a.description}\n\n{b.description}".strip() - items = merge_items(a.items, b.items) - return Section(a.name, type_, desc, items) + return Section(a.name, type_, desc, list(iter_merged_items(a.items, b.items))) -def merge(a: Docstring | None, b: Docstring | None) -> Docstring: # noqa: PLR0912, C901 +def iter_merge_sections(a: list[Section], b: list[Section]) -> Iterator[Section]: + """Yield merged [Section] instances from two lists of [Section].""" + for name in unique_names(a, b): + if name: + ai, bi = get_by_name(a, name), get_by_name(b, name) + if ai and not bi: + yield ai + elif not ai and bi: + yield bi + elif ai and bi: + yield merge_sections(ai, bi) + + +def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: """Merge two [Docstring] instances into one [Docstring] instance.""" - a = a or Docstring("", "", "", []) - b = b or Docstring("", "", "", []) - if not a.sections: + if not a or not a.sections: return b - if not b.sections: + if not b or not b.sections: return a - names = [name for name in unique_names(a.sections, b.sections) if name] sections: list[Section] = [] for ai in a.sections: if ai.name: break sections.append(ai) - for name in names: - ai, bi = get_by_name(a.sections, name), get_by_name(b.sections, name) - if not ai: - sections.append(bi) # type: ignore - elif not bi: - sections.append(ai) # type: ignore - else: - sections.append(merge_sections(ai, bi)) + sections.extend(iter_merge_sections(a.sections, b.sections)) is_named_section = False for section in a.sections: - if section.name: + if section.name: # already collected then skip. is_named_section = True elif is_named_section: sections.append(section) - for section in b.sections: - if not section.name: - sections.append(section) # noqa: PERF401 + sections.extend(s for s in b.sections if not s.name) name_ = a.name if a.name else b.name type_ = a.type if a.type else b.type desc = a.description if a.description else b.description diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index c6bb34d8..7dee99fc 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -482,7 +482,7 @@ def _to_expr(name: str) -> ast.expr: def _merge_attribute_docstring(obj: Attribute) -> None: if doc := obj.docstring: - type_, desc = docstrings.split_attribute(doc) + type_, desc = docstrings.split_without_name(doc, "google") if not obj.type and type_: obj.type = _to_expr(type_) obj.docstring = desc diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 23901cfd..5810509e 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -4,8 +4,8 @@ iter_items, parse, split_item, - split_return, split_section, + split_without_name, ) from mkapi.objects import Module @@ -113,11 +113,11 @@ def test_iter_items_class(google: Module): assert x[1].description == "Description of `param2`. Multiple\nlines are supported." -def test_split_return(google: Module): +def test_split_without_name(google: Module): doc = google.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] - x = split_return(section, "google") + x = split_without_name(section, "google") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index fd755c5a..b7449c9d 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -1,10 +1,10 @@ -from mkapi.docstrings import Item, merge, merge_items, parse +from mkapi.docstrings import Item, iter_merged_items, merge, parse -def test_merge_items(): +def test_iter_merged_items(): a = [Item("a", "", "item a"), Item("b", "int", "item b")] b = [Item("a", "str", "item A"), Item("c", "list", "item c")] - c = merge_items(a, b) + c = list(iter_merged_items(a, b)) assert c[0].name == "a" assert c[0].type == "str" assert c[0].description == "item a" @@ -18,5 +18,6 @@ def test_merge(google): a = parse(google.get("ExampleClass").docstring, "google") b = parse(google.get("ExampleClass").get("__init__").docstring, "google") doc = merge(a, b) + assert doc assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] doc.sections[-1].description.endswith("with it.") diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index bc2d058c..ad30772c 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -3,8 +3,8 @@ _iter_sections, iter_items, split_item, - split_return, split_section, + split_without_name, ) from mkapi.objects import Module @@ -113,7 +113,7 @@ def test_get_return(numpy: Module): doc = numpy.get("module_level_function").docstring # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] - x = split_return(section, "numpy") + x = split_without_name(section, "numpy") assert x[0] == "bool" assert x[1].startswith("True if") assert x[1].endswith(" }") From ac1c607969c674ffb70096c5964ccb0861ff71f1 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 5 Jan 2024 20:49:48 +0900 Subject: [PATCH 059/148] get_fullname --- src/mkapi/docstrings.py | 12 +++++------ src/mkapi/objects.py | 39 +++++++++++++++++++++++++--------- tests/objects/test_fullname.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 tests/objects/test_fullname.py diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index c01bf7ee..c2616dac 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -237,9 +237,9 @@ def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: elif not ai and bi: yield bi elif ai and bi: - name_ = ai.name if ai.name else bi.name - type_ = ai.type if ai.type else bi.type - desc = ai.description if ai.description else bi.description + name_ = ai.name or bi.name + type_ = ai.type or bi.type + desc = ai.description or bi.description yield Item(name_, type_, desc) @@ -284,7 +284,7 @@ def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: elif is_named_section: sections.append(section) sections.extend(s for s in b.sections if not s.name) - name_ = a.name if a.name else b.name - type_ = a.type if a.type else b.type - desc = a.description if a.description else b.description + name_ = a.name or b.name + type_ = a.type or b.type + desc = a.description or b.description return Docstring(name_, type_, desc, sections) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 7dee99fc..e264591d 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -249,19 +249,23 @@ class Callable(Object): # noqa: D101 decorators: list[ast.expr] type_params: list[ast.type_param] raises: list[Raise] + parent: Class | None def get_parameter(self, name: str) -> Parameter | None: # noqa: D102 return get_by_name(self.parameters, name) + def get_fullname(self) -> str: # noqa: D102 + if self.parent: + return f"{self.parent.get_fullname()}.{self.name}" + module_name = self.get_module_name() or "" + return f"{module_name}.{self.name}" + @dataclass(repr=False) class Function(Callable): # noqa: D101 _node: FunctionDef_ returns: Return - def get_node(self) -> FunctionDef_: # noqa: D102 - return self._node - type ClassMember = Parameter | Attribute | Class | Function @@ -304,13 +308,29 @@ def iter_callable_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: def _get_callable_args( node: Def, -) -> tuple[str, str | None, list[Parameter], list[ast.expr], list[ast.type_param]]: +) -> tuple[ + str, + str | None, + list[Parameter], + list[ast.expr], + list[ast.type_param], + list[Raise], +]: name = node.name docstring = ast.get_docstring(node) parameters = [] if isinstance(node, ClassDef) else list(iter_parameters(node)) decorators = node.decorator_list + type_params = node.type_params - return name, docstring, parameters, decorators, type_params + raises = [] if isinstance(node, ClassDef) else list(iter_raises(node)) + return name, docstring, parameters, decorators, type_params, raises + + +def _set_parent(obj: Class) -> None: + for cls in obj.classes: + cls.parent = obj + for func in obj.functions: + func.parent = obj def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: @@ -321,12 +341,12 @@ def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: attrs = list(iter_attributes(def_node)) classes, functions = get_callables(def_node) bases: list[Class] = [] - cls = Class(def_node, *args, [], bases, attrs, classes, functions) + cls = Class(def_node, *args, None, bases, attrs, classes, functions) + _set_parent(cls) _move_property(cls) yield cls else: - raises = list(iter_raises(def_node)) - yield Function(def_node, *args, raises, get_return(def_node)) + yield Function(def_node, *args, None, get_return(def_node)) def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: @@ -498,11 +518,10 @@ def _move_property(obj: Class) -> None: if not _is_property(func): funcs.append(func) continue - node = func.get_node() doc = func.docstring if isinstance(func.docstring, str) else "" type_ = func.returns.type type_params = func.type_params - attr = Attribute(node, func.name, doc, type_, None, type_params) + attr = Attribute(func._node, func.name, doc, type_, None, type_params) # noqa: SLF001 _merge_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs diff --git a/tests/objects/test_fullname.py b/tests/objects/test_fullname.py new file mode 100644 index 00000000..274f8b57 --- /dev/null +++ b/tests/objects/test_fullname.py @@ -0,0 +1,37 @@ +import ast + +from mkapi.objects import _get_module_from_node + +source = """ +class A: + def f(self): + pass + class C: + def g(self): + pass +""" + + +def test_parent(): + node = ast.parse(source) + module = _get_module_from_node(node) + module.name = "m" + a = module.get_class("A") + assert a + f = a.get_function("f") + assert f + c = a.get_class("C") + assert c + g = c.get_function("g") + assert g + assert g.parent is c + assert c.parent is a + assert f.parent is a + + +def test_get_fullname(google): + c = google.get_class("ExampleClass") + f = c.get_function("example_method") + assert c.get_fullname() == "examples.styles.example_google.ExampleClass" + name = "examples.styles.example_google.ExampleClass.example_method" + assert f.get_fullname() == name From ece3699fc6c197957e425d8c8684a64db428fbee Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 6 Jan 2024 14:04:16 +0900 Subject: [PATCH 060/148] plugin nav --- .gitignore | 3 +- examples/custom.py | 21 +-- examples/docs/1.md | 7 + examples/docs/2.md | 7 + examples/docs/3.md | 7 + examples/docs/4.md | 7 + examples/docs/api/mkdocs.structure.md | 3 - examples/docs/example.md | 7 - examples/docs/index.md | 2 +- examples/mkdocs.yml | 12 +- pyproject.toml | 1 + src/mkapi/converter.py | 12 ++ src/mkapi/objects.py | 12 +- src/mkapi/plugins.py | 204 ++++++++++++++------------ src/mkapi/utils.py | 20 ++- tests/converter/test_a.py | 0 tests/objects/test_fullname.py | 1 + tests/test_plugins.py | 123 +++++++++++++++- tests/utils/__init__.py | 0 tests/utils/test_module.py | 27 ++++ 20 files changed, 329 insertions(+), 147 deletions(-) create mode 100644 examples/docs/1.md create mode 100644 examples/docs/2.md create mode 100644 examples/docs/3.md create mode 100644 examples/docs/4.md delete mode 100644 examples/docs/api/mkdocs.structure.md delete mode 100644 examples/docs/example.md create mode 100644 src/mkapi/converter.py create mode 100644 tests/converter/test_a.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_module.py diff --git a/.gitignore b/.gitignore index f1c13f7d..7fb533d4 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ ENV/ # MkDocs documentation site*/ -docs/api +examples/docs/api +examples/docs/api2 lcov.info \ No newline at end of file diff --git a/examples/custom.py b/examples/custom.py index d4c7f0b9..0de8a1f2 100644 --- a/examples/custom.py +++ b/examples/custom.py @@ -1,20 +1,3 @@ -def on_config(): - # Here you can do all you want. - print("Called.") - - -def on_config_with_config(config): - print("Called with config.") - print(config["docs_dir"]) - - # You can change config, for example: - # config['docs_dir'] = 'other_directory' - - # Optionally, you can return altered config to customize MkDocs. - # return config - - -def on_config_with_mkapi(config, mkapi): +def on_config(config, mkapi): print("Called with config and mkapi.") - print(config["docs_dir"]) - print(mkapi) + return config diff --git a/examples/docs/1.md b/examples/docs/1.md new file mode 100644 index 00000000..b63fa068 --- /dev/null +++ b/examples/docs/1.md @@ -0,0 +1,7 @@ +# 1 + +## 1-1 + +## 1-2 + +## 1-3 diff --git a/examples/docs/2.md b/examples/docs/2.md new file mode 100644 index 00000000..839c1e48 --- /dev/null +++ b/examples/docs/2.md @@ -0,0 +1,7 @@ +# 2 + +## 2-1 + +## 2-2 + +## 2-3 diff --git a/examples/docs/3.md b/examples/docs/3.md new file mode 100644 index 00000000..8c4f3b4a --- /dev/null +++ b/examples/docs/3.md @@ -0,0 +1,7 @@ +# 3 + +## 3-1 + +## 3-2 + +## 3-3 diff --git a/examples/docs/4.md b/examples/docs/4.md new file mode 100644 index 00000000..937bbb5b --- /dev/null +++ b/examples/docs/4.md @@ -0,0 +1,7 @@ +# 4 + +## 4-1 + +## 4-2 + +## 4-3 diff --git a/examples/docs/api/mkdocs.structure.md b/examples/docs/api/mkdocs.structure.md deleted file mode 100644 index fb18b16a..00000000 --- a/examples/docs/api/mkdocs.structure.md +++ /dev/null @@ -1,3 +0,0 @@ -# mkdocs.structure - -## mkdocs.structure diff --git a/examples/docs/example.md b/examples/docs/example.md deleted file mode 100644 index 7ec77c65..00000000 --- a/examples/docs/example.md +++ /dev/null @@ -1,7 +0,0 @@ -# Example 123 - -## section 1 - -## section 2 - -## section 3 diff --git a/examples/docs/index.md b/examples/docs/index.md index 2b2af8e8..689ca25b 100644 --- a/examples/docs/index.md +++ b/examples/docs/index.md @@ -1 +1 @@ -# Demo +# Home diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml index 6e146ef7..7a930404 100644 --- a/examples/mkdocs.yml +++ b/examples/mkdocs.yml @@ -13,12 +13,16 @@ plugins: - mkapi: src_dirs: [.] on_config: custom.on_config + filters: [plugin_filter] + exclude: [.tests] nav: - index.md - - /mkdocs.structure - - example.md - # - D: example.md - # - E: example.md + - /mkapi.objects|nav_filter1|nav_filter2 + - Section: + - 1.md + - /mkapi|nav_filter3 + - 2.md + - API: /mkdocs|nav_filter4 extra_css: diff --git a/pyproject.toml b/pyproject.toml index 6a98cc72..8a5ed988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ exclude = ["examples"] [tool.ruff.extend-per-file-ignores] "tests/*.py" = ["ANN", "D", "S101", "INP001", "T201", "PLR2004", "PGH003"] +"plugins.py" = ["ANN", "D", "ARG002"] [tool.ruff.lint] unfixable = [ diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py new file mode 100644 index 00000000..321f5889 --- /dev/null +++ b/src/mkapi/converter.py @@ -0,0 +1,12 @@ +"""Converter.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mkapi.objects import Module + + +def convert(module: Module) -> str: + """Convert the [Module] instance to markdown text.""" + return f"# {module.name}" diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index e264591d..36947c6a 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -38,7 +38,6 @@ type Assign_ = Assign | AnnAssign | TypeAlias CURRENT_MODULE_NAME: list[str | None] = [None] -current_docstring_style: list[Style] = ["google"] @dataclass @@ -254,11 +253,11 @@ class Callable(Object): # noqa: D101 def get_parameter(self, name: str) -> Parameter | None: # noqa: D102 return get_by_name(self.parameters, name) - def get_fullname(self) -> str: # noqa: D102 + def get_fullname(self, sep: str = ".") -> str: # noqa: D102 if self.parent: return f"{self.parent.get_fullname()}.{self.name}" module_name = self.get_module_name() or "" - return f"{module_name}.{self.name}" + return f"{module_name}{sep}{self.name}" @dataclass(repr=False) @@ -527,13 +526,16 @@ def _move_property(obj: Class) -> None: obj.functions = funcs +CURRENT_DOCSTRING_STYLE: list[Style] = ["google"] + + def _get_style(doc: str) -> Style: for names in docstrings.SECTION_NAMES: for name in names: if f"\n\n{name}\n----" in doc: - current_docstring_style[0] = "numpy" + CURRENT_DOCSTRING_STYLE[0] = "numpy" return "numpy" - current_docstring_style[0] = "google" + CURRENT_DOCSTRING_STYLE[0] = "google" return "google" diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 28b77e53..aa2e425f 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -6,15 +6,15 @@ from __future__ import annotations import atexit +import importlib import inspect import logging import os import re import shutil import sys -from collections.abc import Callable from pathlib import Path -from typing import TypeGuard +from typing import TYPE_CHECKING, TypeGuard import yaml from mkdocs.config import config_options @@ -30,6 +30,11 @@ import mkapi.config from mkapi.filter import split_filters, update_filters +from mkapi.objects import Module, get_module +from mkapi.utils import find_submodule_names, is_package + +if TYPE_CHECKING: + from collections.abc import Callable # from mkapi.modules import Module, get_module @@ -38,16 +43,16 @@ # from mkapi.core.page import Page as MkAPIPage logger = logging.getLogger("mkdocs") -global_config = {} class MkAPIConfig(Config): """Specify the config schema.""" src_dirs = config_options.Type(list, default=[]) - on_config = config_options.Type(str, default="") filters = config_options.Type(list, default=[]) + exclude = config_options.Type(list, default=[]) callback = config_options.Type(str, default="") + on_config = config_options.Type(str, default="") abs_api_paths = config_options.Type(list, default=[]) pages = config_options.Type(dict, default={}) @@ -57,7 +62,7 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): server = None - def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: ARG002, D102 + def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: _insert_sys_path(self.config) _update_config(config, self) if "admonition" not in config.markdown_extensions: @@ -146,16 +151,10 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: # noqa: AR # clear_prefix(page.toc, level, id_) # return context - def on_serve( # noqa: D102 - self, - server: LiveReloadServer, - config: MkDocsConfig, # noqa: ARG002 - builder: Callable, - **kwargs, # noqa: ARG002 - ) -> LiveReloadServer: - # for path in ["theme", "templates"]: - # path_str = (Path(mkapi.__file__).parent / path).as_posix() - # server.watch(path_str, builder) + def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): + for path in ["themes", "templates"]: + path_str = (Path(mkapi.__file__).parent / path).as_posix() + server.watch(path_str, builder) self.__class__.server = server return server @@ -163,128 +162,139 @@ def on_serve( # noqa: D102 def _insert_sys_path(config: MkAPIConfig) -> None: config_dir = Path(config.config_file_path).parent for src_dir in config.src_dirs: - if (path := os.path.normpath(config_dir / src_dir)) not in sys.path: + path = os.path.normpath(config_dir / src_dir) + if path not in sys.path: sys.path.insert(0, path) - if not config.src_dirs and (path := Path.cwd()) not in sys.path: - sys.path.insert(0, str(path)) + + +CACHE_CONFIG = {} def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: if not plugin.server: - plugin.config.abs_api_paths = _update_nav(config, plugin.config.filters) - global_config["nav"] = config.nav - global_config["abs_api_paths"] = plugin.config.abs_api_paths + abs_api_paths = _update_nav(config, plugin) + plugin.config.abs_api_paths = abs_api_paths + CACHE_CONFIG["abs_api_paths"] = abs_api_paths + CACHE_CONFIG["nav"] = config.nav else: - config.nav = global_config["nav"] - plugin.config.abs_api_paths = global_config["abs_api_paths"] - + plugin.config.abs_api_paths = CACHE_CONFIG["abs_api_paths"] + config.nav = CACHE_CONFIG["nav"] -def _update_nav(config: MkDocsConfig, filters: list[str]) -> list[Path]: - if not isinstance(config.nav, list): - return [] - def create_api_nav(item: str) -> list: - nav, paths = _collect(item, config.docs_dir, filters) +def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[Path]: + def create_pages(item: str) -> list: + nav, paths = _collect(item, config.docs_dir, filters, _create_page) abs_api_paths.extend(paths) return nav + filters = plugin.config.filters abs_api_paths: list[Path] = [] - _walk_nav(config.nav, create_api_nav) - print("AAAAAAAAAAAAAAAAAAA\n", config.nav) - abs_api_paths = list(set(abs_api_paths)) - return abs_api_paths + config.nav = _walk_nav(config.nav, create_pages) # type: ignore + return list(set(abs_api_paths)) -def _walk_nav(nav: list | dict, create_api_nav: Callable[[str], list]) -> None: - print("DDD", nav) - it = enumerate(nav) if isinstance(nav, list) else nav.items() - for k, item in it: +def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: + nav_ = [] + for item in nav: if _is_api_entry(item): - api_nav = create_api_nav(item) - print("EEE", k, api_nav, type(nav)) - # nav[k] = api_nav if isinstance(nav, dict) else {item: api_nav} - nav[k] = {"AAAA": [api_nav]} - print("FFF", nav) - elif isinstance(item, list | dict): - _walk_nav(item, create_api_nav) + nav_.extend(create_pages(item)) + elif isinstance(item, dict) and len(item) == 1: + key = next(iter(item.keys())) + value = item[key] + if _is_api_entry(value): + value = create_pages(value) + if len(value) == 1 and isinstance(value[0], str): + value = value[0] + elif isinstance(value, list): + value = _walk_nav(value, create_pages) + nav_.append({key: value}) + else: + nav_.append(item) + return nav_ API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: - return isinstance(item, str) and re.match(API_URL_PATTERN, item) is not None + if not isinstance(item, str): + return False + return re.match(API_URL_PATTERN, item) is not None -def _collect(item: str, docs_dir: str, filters: list[str]) -> tuple[list, list[Path]]: - """Collect modules.""" +def _get_path_module_name_filters( + item: str, + filters: list[str], +) -> tuple[str, str, list[str]]: if not (m := re.match(API_URL_PATTERN, item)): raise NotImplementedError - api_path, module_name = m.groups() + path, module_name_filter = m.groups() + module_name, filters_ = split_filters(module_name_filter) + filters = update_filters(filters, filters_) + return path, module_name, filters + + +def _create_nav( + name: str, + callback: Callable[[str], str | dict[str, str]], + section: Callable[[str], str | None] | None = None, + predicate: Callable[[str], bool] | None = None, +) -> list: + names = find_submodule_names(name, predicate) + names.sort(key=lambda x: not find_submodule_names(x, predicate)) + tree: list = [callback(name)] + for sub in names: + if not is_package(sub): + tree.append(callback(sub)) + continue + subtree = _create_nav(sub, callback, section, predicate) + if (n := len(subtree)) == 1: + tree.extend(subtree) + elif n > 1: + title = section(sub) if section else sub + tree.append({title: subtree}) + return tree + + +def _collect( + item: str, + docs_dir: str, + filters: list[str], + create_page: Callable[[str, Path, list[str]], None], +) -> tuple[list, list[Path]]: + """Collect modules.""" + api_path, name, filters = _get_path_module_name_filters(item, filters) abs_api_path = Path(docs_dir) / api_path Path.mkdir(abs_api_path, parents=True, exist_ok=True) - module_name, filters_ = split_filters(module_name) - filters = update_filters(filters, filters_) - def add_module(module: Module, package: str | None) -> None: - module_path = module.name + ".md" + def callback(name: str) -> dict[str, str]: + module_path = name + ".md" abs_module_path = abs_api_path / module_path abs_api_paths.append(abs_module_path) - _create_page(abs_module_path, module, filters) - module_name = module.name - if package and "short_nav" in filters and module_name != package: - module_name = module_name[len(package) + 1 :] - modules[module_name] = (Path(api_path) / module_path).as_posix() - # abs_source_path = abs_api_path / "source" / module_path - # create_source_page(abs_source_path, module, filters) + create_page(name, abs_module_path, filters) + nav_path = (Path(api_path) / module_path).as_posix() + return {name: nav_path} # TODO: page tile abs_api_paths: list[Path] = [] - modules: dict[str, str] = {} - nav, package = [], None - module = get_module(module_name) - print(module.get_tree()) - add_module(module, None) - nav = modules - - # if not module.is_package(): - # pass # TODO - - # for module in get_module(module_name): - # if module.is_package(): - # if package and modules: - # nav.append({package: modules}) - # package = module.name - # modules = {} - # add_module(module, package) # Skip if no docstring. - # else: - # add_module(module, package) - # if package and modules: - # nav.append({package: modules}) - # if modules: - # nav.append({package or module.name: modules}) - + nav = _create_nav(name, callback) # TODO: exclude return nav, abs_api_paths -def _create_page(path: Path, module: Module, filters: list[str]) -> None: +def _create_page(name: str, path: Path, filters: list[str]) -> None: """Create a page.""" with path.open("w") as f: - f.write(module.get_markdown(filters)) + f.write(f"# {name}") def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: - # if plugin.config.on_config: - # on_config = get_object(plugin.config.on_config) - # kwargs, params = {}, inspect.signature(on_config).parameters - # if "config" in params: - # kwargs["config"] = config - # if "plugin" in params: - # kwargs["plugin"] = plugin - # msg = f"[MkAPI] Calling user 'on_config' with {list(kwargs)}" - # logger.info(msg) - # config_ = on_config(**kwargs) - # if isinstance(config_, MkDocsConfig): - # return config_ + if plugin.config.on_config: + module_name, func_name = plugin.config.on_config.rsplit(".", maxsplit=1) + module = importlib.import_module(module_name) + func = getattr(module, func_name) + logger.info("[MkAPI] Calling user 'on_config' with") + config_ = func(config, plugin) + if isinstance(config_, MkDocsConfig): + return config_ return config diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index f9aa2ba1..5ac0b048 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -7,7 +7,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator + from typing import Literal def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: @@ -23,7 +24,8 @@ def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: return False -def _is_package(name: str) -> bool: +def is_package(name: str) -> bool: + """Return True if the name is a package.""" if (spec := find_spec(name)) and spec.origin: return Path(spec.origin).stem == "__init__" return False @@ -40,10 +42,16 @@ def iter_submodule_names(name: str) -> Iterator[str]: yield f"{name}.{path.stem}" -def find_submodule_names(name: str) -> list[str]: - """Return a list of submodules.""" - names = iter_submodule_names(name) - return sorted(names, key=lambda name: not _is_package(name)) +def find_submodule_names( + name: str, + predicate: Callable[[str], bool] | None = None, +) -> list[str]: + """Return a list of submodule names. + + Optionally, only return submodules that satisfy a given predicate. + """ + predicate = predicate or (lambda _: True) + return [name for name in iter_submodule_names(name) if predicate(name)] def delete_ptags(html: str) -> str: diff --git a/tests/converter/test_a.py b/tests/converter/test_a.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/objects/test_fullname.py b/tests/objects/test_fullname.py index 274f8b57..91027cef 100644 --- a/tests/objects/test_fullname.py +++ b/tests/objects/test_fullname.py @@ -35,3 +35,4 @@ def test_get_fullname(google): assert c.get_fullname() == "examples.styles.example_google.ExampleClass" name = "examples.styles.example_google.ExampleClass.example_method" assert f.get_fullname() == name + assert c.get_fullname("#") == "examples.styles.example_google#ExampleClass" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 8eba013c..15eff6a9 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,3 +1,4 @@ +import importlib.util from pathlib import Path import pytest @@ -8,18 +9,33 @@ from mkdocs.plugins import PluginCollection from mkdocs.theme import Theme -from mkapi.plugins import MkAPIConfig, MkAPIPlugin +import mkapi +from mkapi.plugins import ( + MkAPIConfig, + MkAPIPlugin, + _collect, + _create_nav, + _get_path_module_name_filters, + _insert_sys_path, + _on_config_plugin, + _walk_nav, +) @pytest.fixture(scope="module") def config_file(): - return Path(__file__).parent.parent / "examples" / "mkdocs.yml" + return Path(mkapi.__file__).parent.parent.parent / "examples" / "mkdocs.yml" def test_config_file_exists(config_file: Path): assert config_file.exists() +def test_themes_templates_exists(): + for path in ["themes", "templates"]: + assert (Path(mkapi.__file__).parent / path).exists() + + @pytest.fixture(scope="module") def mkdocs_config(config_file: Path): return load_config(str(config_file)) @@ -51,6 +67,7 @@ def mkapi_plugin(mkdocs_config: MkDocsConfig): def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): + assert isinstance(mkapi_plugin, MkAPIPlugin) assert mkapi_plugin.server is None assert isinstance(mkapi_plugin.config, MkAPIConfig) @@ -62,10 +79,108 @@ def mkapi_config(mkapi_plugin: MkAPIPlugin): def test_mkapi_config(mkapi_config: MkAPIConfig): config = mkapi_config - x = ["src_dirs", "on_config", "filters", "callback", "abs_api_paths", "pages"] - assert list(config) == x assert config.src_dirs == ["."] assert config.on_config == "custom.on_config" + assert config.filters == ["plugin_filter"] + assert config.exclude == [".tests"] + assert config.abs_api_paths == [] + + +def test_insert_sys_path(mkapi_config: MkAPIConfig): + assert not importlib.util.find_spec("custom") + _insert_sys_path(mkapi_config) + spec = importlib.util.find_spec("custom") + assert spec + assert spec.origin + assert spec.origin.endswith("custom.py") + + +def test_on_config_plugin(mkdocs_config, mkapi_plugin): + config = _on_config_plugin(mkdocs_config, mkapi_plugin) + assert mkdocs_config is config + + +@pytest.fixture(scope="module") +def nav(mkdocs_config: MkDocsConfig): + return mkdocs_config.nav + + +def test_nav_before_update(nav): + assert isinstance(nav, list) + assert nav[0] == "index.md" + assert nav[1] == "/mkapi.objects|nav_filter1|nav_filter2" + assert nav[2] == {"Section": ["1.md", "/mkapi|nav_filter3", "2.md"]} + assert nav[3] == {"API": "/mkdocs|nav_filter4"} + + +def test_walk_nav(nav): + def create_pages(item: str) -> list: + print(item) + items.append(item.split("|")[-1]) + if item.endswith("filter4"): + return ["a"] + return ["b", "c"] + + items = [] + nav = _walk_nav(nav, create_pages) + assert items == ["nav_filter2", "nav_filter3", "nav_filter4"] + assert isinstance(nav, list) + assert nav[1:3] == ["b", "c"] + assert nav[3] == {"Section": ["1.md", "b", "c", "2.md"]} + assert nav[4] == {"API": "a"} + + +def test_get_path_module_name_filters(): + p, m, f = _get_path_module_name_filters("/a.b|f1|f2", ["f"]) + assert p == "api" + assert m == "a.b" + assert f == ["f", "f1", "f2"] + p, m, f = _get_path_module_name_filters("/a", ["f"]) + assert p == "api" + assert m == "a" + assert f == ["f"] + + +def test_create_nav(): + def callback(name): + return {name.upper(): f"test/{name}.md"} + + def section(name): + return name.replace(".", "-") + + f = _create_nav + a = [{"MKDOCS.PLUGINS": "test/mkdocs.plugins.md"}] + assert f("mkdocs.plugins", callback) == a + a = [{"MKDOCS.LIVERELOAD": "test/mkdocs.livereload.md"}] + assert f("mkdocs.livereload", callback) == a + nav = f("mkdocs.commands", callback) + assert nav[0] == {"MKDOCS.COMMANDS": "test/mkdocs.commands.md"} + assert nav[-1] == {"MKDOCS.COMMANDS.SERVE": "test/mkdocs.commands.serve.md"} + nav = f("mkdocs", callback, section, lambda x: "tests" not in x) + assert len(nav[1]) == 1 + assert "mkdocs-commands" in nav[1] + + +def test_collect(mkdocs_config: MkDocsConfig): + def create_page(name: str, path: Path, filters: list[str]) -> None: + assert "mkapi" in name + assert "examples" in path.as_posix() + assert filters == ["A", "F", "G"] + + docs_dir = mkdocs_config.docs_dir + nav, paths = _collect("/mkapi|F|G", docs_dir, ["A"], create_page) + assert nav[0] == {"mkapi": "a/b/c/mkapi.md"} + + +# def test_walk_module_tree(): +# tree = find_submodule_tree("mkdocs", ) +# _walk_module_tree(tree) +# assert 0 + + +# def test_collect(mkdocs_config: MkDocsConfig): +# docs_dir = mkdocs_config.docs_dir +# assert 0 # @pytest.fixture(scope="module") diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/test_module.py b/tests/utils/test_module.py new file mode 100644 index 00000000..2239ed0a --- /dev/null +++ b/tests/utils/test_module.py @@ -0,0 +1,27 @@ +from mkapi.utils import ( + find_submodule_names, + is_package, + iter_submodule_names, +) + + +def test_is_package(): + assert is_package("mkdocs") + assert is_package("mkapi") + assert not is_package("mkapi.objects") + + +def test_iter_submodule_names(): + for name in iter_submodule_names("mkdocs"): + assert name.startswith("mkdocs.") + for name in iter_submodule_names("mkdocs.structure"): + assert name.startswith("mkdocs.structure") + + +def test_find_submodule_names(): + names = find_submodule_names("mkdocs", lambda x: "tests" not in x) + assert "mkdocs.plugins" in names + assert "mkdocs.tests" not in names + names = find_submodule_names("mkdocs", is_package) + assert "mkdocs.structure" in names + assert "mkdocs.plugins" not in names From 50e1a4a311a663a6686e9e9d3514835ad09f20a3 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 6 Jan 2024 15:27:29 +0900 Subject: [PATCH 061/148] add nav depth --- examples/custom.py | 8 ++++ examples/docs/3.md | 7 ---- examples/docs/4.md | 7 ---- examples/mkdocs.yml | 3 ++ src/mkapi/plugins.py | 90 +++++++++++++++++++++++++------------------ src/mkapi/utils.py | 4 +- tests/test_plugins.py | 27 +++++++------ 7 files changed, 80 insertions(+), 66 deletions(-) delete mode 100644 examples/docs/3.md delete mode 100644 examples/docs/4.md diff --git a/examples/custom.py b/examples/custom.py index 0de8a1f2..5b50f4f6 100644 --- a/examples/custom.py +++ b/examples/custom.py @@ -1,3 +1,11 @@ def on_config(config, mkapi): print("Called with config and mkapi.") return config + + +def page_title(module_name: str, depth: int, ispackage: bool) -> str: + return ".".join(module_name.split(".")[depth:]) + + +def section_title(package_name: str, depth: int) -> str: + return ".".join(package_name.split(".")[depth:]) diff --git a/examples/docs/3.md b/examples/docs/3.md deleted file mode 100644 index 8c4f3b4a..00000000 --- a/examples/docs/3.md +++ /dev/null @@ -1,7 +0,0 @@ -# 3 - -## 3-1 - -## 3-2 - -## 3-3 diff --git a/examples/docs/4.md b/examples/docs/4.md deleted file mode 100644 index 937bbb5b..00000000 --- a/examples/docs/4.md +++ /dev/null @@ -1,7 +0,0 @@ -# 4 - -## 4-1 - -## 4-2 - -## 4-3 diff --git a/examples/mkdocs.yml b/examples/mkdocs.yml index 7a930404..7e8b14e6 100644 --- a/examples/mkdocs.yml +++ b/examples/mkdocs.yml @@ -15,6 +15,9 @@ plugins: on_config: custom.on_config filters: [plugin_filter] exclude: [.tests] + page_title: custom.page_title + section_title: custom.section_title + nav: - index.md - /mkapi.objects|nav_filter1|nav_filter2 diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index aa2e425f..c91a98fe 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -51,7 +51,8 @@ class MkAPIConfig(Config): src_dirs = config_options.Type(list, default=[]) filters = config_options.Type(list, default=[]) exclude = config_options.Type(list, default=[]) - callback = config_options.Type(str, default="") + page_title = config_options.Type(str, default="") + section_title = config_options.Type(str, default="") on_config = config_options.Type(str, default="") abs_api_paths = config_options.Type(list, default=[]) pages = config_options.Type(dict, default={}) @@ -181,18 +182,6 @@ def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: config.nav = CACHE_CONFIG["nav"] -def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[Path]: - def create_pages(item: str) -> list: - nav, paths = _collect(item, config.docs_dir, filters, _create_page) - abs_api_paths.extend(paths) - return nav - - filters = plugin.config.filters - abs_api_paths: list[Path] = [] - config.nav = _walk_nav(config.nav, create_pages) # type: ignore - return list(set(abs_api_paths)) - - def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: nav_ = [] for item in nav: @@ -213,6 +202,17 @@ def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: return nav_ +def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[Path]: + def create_pages(item: str) -> list: + nav, paths = _collect(item, config, plugin) + abs_api_paths.extend(paths) + return nav + + abs_api_paths: list[Path] = [] + config.nav = _walk_nav(config.nav, create_pages) # type: ignore + return list(set(abs_api_paths)) + + API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") @@ -236,47 +236,65 @@ def _get_path_module_name_filters( def _create_nav( name: str, - callback: Callable[[str], str | dict[str, str]], - section: Callable[[str], str | None] | None = None, + callback: Callable[[str, int, bool], str | dict[str, str]], + section: Callable[[str, int], str | None] | None = None, predicate: Callable[[str], bool] | None = None, + depth: int = 0, ) -> list: names = find_submodule_names(name, predicate) - names.sort(key=lambda x: not find_submodule_names(x, predicate)) - tree: list = [callback(name)] + tree: list = [callback(name, depth, is_package(name))] for sub in names: if not is_package(sub): - tree.append(callback(sub)) + tree.append(callback(sub, depth, False)) # noqa: FBT003 continue - subtree = _create_nav(sub, callback, section, predicate) - if (n := len(subtree)) == 1: - tree.extend(subtree) - elif n > 1: - title = section(sub) if section else sub + subtree = _create_nav(sub, callback, section, predicate, depth + 1) + # if (n := len(subtree)) == 1: + # tree.extend(subtree) + # elif n > 1: + if len(subtree): + title = section(sub, depth) if section else sub tree.append({title: subtree}) return tree +def _get_function(plugin: MkAPIPlugin, name: str) -> Callable | None: + if fullname := plugin.config.get(name, None): + module_name, func_name = fullname.rsplit(".", maxsplit=1) + module = importlib.import_module(module_name) + return getattr(module, func_name) + return None + + def _collect( item: str, - docs_dir: str, - filters: list[str], - create_page: Callable[[str, Path, list[str]], None], + config: MkDocsConfig, + plugin: MkAPIPlugin, ) -> tuple[list, list[Path]]: """Collect modules.""" - api_path, name, filters = _get_path_module_name_filters(item, filters) - abs_api_path = Path(docs_dir) / api_path + api_path, name, filters = _get_path_module_name_filters(item, plugin.config.filters) + abs_api_path = Path(config.docs_dir) / api_path Path.mkdir(abs_api_path, parents=True, exist_ok=True) - def callback(name: str) -> dict[str, str]: + page_title = _get_function(plugin, "page_title") + section_title = _get_function(plugin, "section_title") + if plugin.config.exclude: + + def predicate(name: str) -> bool: + return all(e not in name for e in plugin.config.exclude) + else: + predicate = None # type: ignore + + def callback(name: str, depth: int, ispackage) -> dict[str, str]: module_path = name + ".md" abs_module_path = abs_api_path / module_path abs_api_paths.append(abs_module_path) - create_page(name, abs_module_path, filters) + _create_page(name, abs_module_path, filters) nav_path = (Path(api_path) / module_path).as_posix() - return {name: nav_path} # TODO: page tile + title = page_title(name, depth, ispackage) if page_title else name + return {title: nav_path} abs_api_paths: list[Path] = [] - nav = _create_nav(name, callback) # TODO: exclude + nav = _create_nav(name, callback, section_title, predicate) return nav, abs_api_paths @@ -287,11 +305,9 @@ def _create_page(name: str, path: Path, filters: list[str]) -> None: def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: - if plugin.config.on_config: - module_name, func_name = plugin.config.on_config.rsplit(".", maxsplit=1) - module = importlib.import_module(module_name) - func = getattr(module, func_name) - logger.info("[MkAPI] Calling user 'on_config' with") + if func := _get_function(plugin, "on_config"): + msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" + logger.info(msg) config_ = func(config, plugin) if isinstance(config_, MkDocsConfig): return config_ diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 5ac0b048..0827a2a8 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -51,7 +51,9 @@ def find_submodule_names( Optionally, only return submodules that satisfy a given predicate. """ predicate = predicate or (lambda _: True) - return [name for name in iter_submodule_names(name) if predicate(name)] + names = [name for name in iter_submodule_names(name) if predicate(name)] + names.sort(key=lambda x: not is_package(x)) + return names def delete_ptags(html: str) -> str: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 15eff6a9..31da9eb9 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -13,7 +13,6 @@ from mkapi.plugins import ( MkAPIConfig, MkAPIPlugin, - _collect, _create_nav, _get_path_module_name_filters, _insert_sys_path, @@ -142,10 +141,10 @@ def test_get_path_module_name_filters(): def test_create_nav(): - def callback(name): + def callback(name, depth, ispackage): # noqa: ARG001 return {name.upper(): f"test/{name}.md"} - def section(name): + def section(name, depth): # noqa: ARG001 return name.replace(".", "-") f = _create_nav @@ -161,17 +160,6 @@ def section(name): assert "mkdocs-commands" in nav[1] -def test_collect(mkdocs_config: MkDocsConfig): - def create_page(name: str, path: Path, filters: list[str]) -> None: - assert "mkapi" in name - assert "examples" in path.as_posix() - assert filters == ["A", "F", "G"] - - docs_dir = mkdocs_config.docs_dir - nav, paths = _collect("/mkapi|F|G", docs_dir, ["A"], create_page) - assert nav[0] == {"mkapi": "a/b/c/mkapi.md"} - - # def test_walk_module_tree(): # tree = find_submodule_tree("mkdocs", ) # _walk_module_tree(tree) @@ -195,3 +183,14 @@ def create_page(name: str, path: Path, filters: list[str]) -> None: # build(config, dirty=False) # finally: # config.plugins.on_shutdown() +def on_config(config, mkapi): + print("Called with config and mkapi.") + return config + + +def module_title(module_name: str) -> str: + return module_name.rsplit(".")[-1] + + +def section_title(package_name: str) -> str: + return package_name.upper() From 7d933cfd700b509ce88139d73d4307f6b9797bfa Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 6 Jan 2024 20:24:19 +0900 Subject: [PATCH 062/148] Page --- src/mkapi/converter.py | 20 +- src/mkapi/link.py | 205 ++++++++++++++++++ src/mkapi/objects.py | 20 +- src/mkapi/pages.py | 90 ++++++++ src/mkapi/plugins.py | 176 +++++++-------- src/mkapi/renderer.py | 172 +++++++++++++++ src/mkapi/templates/module.jinja2 | 12 +- src/mkapi/utils.py | 1 - src_old/mkapi/core/link.py | 204 ----------------- src_old/mkapi/core/page.py | 111 ---------- src_old/mkapi/core/renderer.py | 181 ---------------- tests/objects/test_object.py | 23 +- tests/{utils => pages}/__init__.py | 0 tests/pages/test_page.py | 54 +++++ tests/test_link.py | 38 ++++ tests/test_renderer.py | 28 +++ tests/{utils/test_module.py => test_utils.py} | 0 tests_old/core/test_core_link.py | 38 ---- tests_old/core/test_core_page.py | 50 ----- tests_old/core/test_core_renderer.py | 22 -- 20 files changed, 720 insertions(+), 725 deletions(-) create mode 100644 src/mkapi/link.py create mode 100644 src/mkapi/pages.py create mode 100644 src/mkapi/renderer.py delete mode 100644 src_old/mkapi/core/link.py delete mode 100644 src_old/mkapi/core/page.py delete mode 100644 src_old/mkapi/core/renderer.py rename tests/{utils => pages}/__init__.py (100%) create mode 100644 tests/pages/test_page.py create mode 100644 tests/test_link.py create mode 100644 tests/test_renderer.py rename tests/{utils/test_module.py => test_utils.py} (100%) delete mode 100644 tests_old/core/test_core_link.py delete mode 100644 tests_old/core/test_core_page.py delete mode 100644 tests_old/core/test_core_renderer.py diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py index 321f5889..ac627a5f 100644 --- a/src/mkapi/converter.py +++ b/src/mkapi/converter.py @@ -1,12 +1,20 @@ """Converter.""" from __future__ import annotations -from typing import TYPE_CHECKING +from mkapi.objects import get_module +from mkapi.renderer import renderer -if TYPE_CHECKING: - from mkapi.objects import Module - -def convert(module: Module) -> str: +def convert_module(name: str, filters: list[str]) -> str: """Convert the [Module] instance to markdown text.""" - return f"# {module.name}" + if module := get_module(name): + return renderer.render_module(module) + return f"{name} not found" + + +def convert_object(name: str, level: int) -> str: + return "xxxx" + + +def convert_html(name: str, html: str, filters: list[str]) -> str: + return "xxxx" diff --git a/src/mkapi/link.py b/src/mkapi/link.py new file mode 100644 index 00000000..7ca7adc7 --- /dev/null +++ b/src/mkapi/link.py @@ -0,0 +1,205 @@ +"""Provide functions that relate to linking functionality.""" +import os +import re +from pathlib import Path + +# import warnings +# from html.parser import HTMLParser +# from typing import Any + +# # from mkapi.core.object import get_fullname + +LINK_PATTERN = re.compile(r"\[(\S+?)\]\[(\S+?)\]") + + +def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> str: + """Reutrn resolved link. + + Args: + markdown: Markdown source. + abs_src_path: Absolute source path of Markdown. + abs_api_paths: List of API paths. + + Examples: + >>> abs_src_path = '/src/examples/example.md' + >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] + >>> resolve_link('[abc][b.c.d]', abs_src_path, abs_api_paths) + '[abc](../../api/b.c#b.c.d)' + >>> resolve_link('[abc][!b.c.d]', abs_src_path, abs_api_paths) + '[abc](../../api/b.c#b.c.d)' + """ + + def replace(match: re.Match) -> str: + name, href = match.groups() + if href.startswith("!!"): # Just for MkAPI documentation. + href = href[2:] + return f"[{name}]({href})" + from_mkapi = False + if href.startswith("!"): + href = href[1:] + from_mkapi = True + + if href := _resolve_href(href, abs_src_path, abs_api_paths): + return f"[{name}]({href})" + return name if from_mkapi else match.group() + + return re.sub(LINK_PATTERN, replace, markdown) + + +def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: + if not name: + return "" + abs_api_path = _match_last(name, abs_api_paths) + if not abs_api_path: + return "" + relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) + relpath = relpath.replace("\\", "/") + return f"{relpath}#{name}" + + +def _match_last(name: str, abs_api_paths: list[str]) -> str: + match = "" + for abs_api_path in abs_api_paths: + _, path = os.path.split(abs_api_path) + if name.startswith(path[:-3]): + match = abs_api_path + return match + + +# REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") + + +# def link(name: str, href: str) -> str: +# """Return Markdown link with a mark that indicates this link was created by MkAPI. + +# Args: +# name: Link name. +# href: Reference. + +# Examples: +# >>> link('abc', 'xyz') +# '[abc](!xyz)' +# """ +# return f"[{name}](!{href})" + + +# def get_link(obj: type, *, include_module: bool = False) -> str: +# """Return Markdown link for object, if possible. + +# Args: +# obj: Object +# include_module: If True, link text includes module path. + +# Examples: +# >>> get_link(int) +# 'int' +# >>> get_link(get_fullname) +# '[get_fullname](!mkapi.core.object.get_fullname)' +# >>> get_link(get_fullname, include_module=True) +# '[mkapi.core.object.get_fullname](!mkapi.core.object.get_fullname)' +# """ +# if hasattr(obj, "__qualname__"): +# name = obj.__qualname__ +# elif hasattr(obj, "__name__"): +# name = obj.__name__ +# else: +# msg = f"obj has no name: {obj}" +# warnings.warn(msg, stacklevel=1) +# return str(obj) + +# if not hasattr(obj, "__module__") or (module := obj.__module__) == "builtins": +# return name +# fullname = f"{module}.{name}" +# text = fullname if include_module else name +# if obj.__name__.startswith("_"): +# return text +# return link(text, fullname) + + +# class _ObjectParser(HTMLParser): +# def feed(self, html: str) -> dict[str, Any]: +# self.context = {"href": [], "heading_id": ""} +# super().feed(html) +# href = self.context["href"] +# if len(href) == 2: # noqa: PLR2004 +# prefix_url, name_url = href +# elif len(href) == 1: +# prefix_url, name_url = "", href[0] +# else: +# prefix_url, name_url = "", "" +# self.context["prefix_url"] = prefix_url +# self.context["name_url"] = name_url +# del self.context["href"] +# return self.context + +# def handle_starttag(self, tag: str, attrs: list[str]) -> None: +# context = self.context +# if tag == "p": +# context["level"] = 0 +# elif re.match(r"h[1-6]", tag): +# context["level"] = int(tag[1:]) +# for attr in attrs: +# if attr[0] == "id": +# self.context["heading_id"] = attr[1] +# elif tag == "a": +# for attr in attrs: +# if attr[0] == "href": +# href = attr[1] +# if href.startswith("./"): +# href = href[2:] +# self.context["href"].append(href) + + +# parser = _ObjectParser() + + +# def resolve_object(html: str) -> dict[str, Any]: +# """Reutrn an object context dictionary. + +# Args: +# html: HTML source. + +# Examples: +# >>> resolve_object("

pn

") +# {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} +# >>> resolve_object("

pn

") +# {'heading_id': 'i', 'level': 2, 'prefix_url': 'a', 'name_url': 'b'} +# """ +# parser.reset() +# return parser.feed(html) + + +# def replace_link(obj: object, markdown: str) -> str: +# """Return a replaced link with object's full name. + +# Args: +# obj: Object that has a module. +# markdown: Markdown + +# Examples: +# >>> from mkapi.core.object import get_object +# >>> obj = get_object('mkapi.core.structure.Object') +# >>> replace_link(obj, '[Signature]()') +# '[Signature](!mkapi.inspect.signature.Signature)' +# >>> replace_link(obj, '[](Signature)') +# '[Signature](!mkapi.inspect.signature.Signature)' +# >>> replace_link(obj, '[text](Signature)') +# '[text](!mkapi.inspect.signature.Signature)' +# >>> replace_link(obj, '[dummy.Dummy]()') +# '[dummy.Dummy]()' +# >>> replace_link(obj, 'Signature_') +# '[Signature](!mkapi.inspect.signature.Signature)' +# """ + +# def replace(match: re.Match) -> str: +# text, name, rest = match.groups() +# if rest: +# name, text = rest, "" +# elif not name: +# name, text = text, "" +# fullname = get_fullname(obj, name) +# if fullname == "": +# return match.group() +# return link(text or name, fullname) + +# return re.sub(REPLACE_LINK_PATTERN, replace, markdown) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 36947c6a..a1d7677d 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING from mkapi import docstrings -from mkapi.utils import del_by_name, get_by_name, unique_names +from mkapi.utils import del_by_name, get_by_name, is_package, unique_names if TYPE_CHECKING: from ast import AST @@ -259,6 +259,10 @@ def get_fullname(self, sep: str = ".") -> str: # noqa: D102 module_name = self.get_module_name() or "" return f"{module_name}{sep}{self.name}" + @property + def id(self) -> str: # noqa: D102, A003 + return self.get_fullname() + @dataclass(repr=False) class Function(Callable): # noqa: D101 @@ -371,6 +375,7 @@ class Module(Object): # noqa: D101 classes: list[Class] functions: list[Function] source: str + kind: str def get_import(self, name: str) -> Import | None: # noqa: D102 return get_by_name(self.imports, name) @@ -399,6 +404,14 @@ def get_source(self) -> str: """Return the source code.""" return _get_module_source(self.name) if self.name else "" + @property + def id(self) -> str: # noqa: D102, A003 + return self.name + + @property + def members(self) -> list[Class | Function]: # noqa: D102 + return self.classes + self.functions + CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module | None, str]] = {} CACHE_MODULE: dict[str, Module | None] = {} @@ -439,12 +452,12 @@ def _get_module_from_node(node: ast.Module) -> Module: imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) - return Module(node, "", docstring, imports, attrs, classes, functions, "") + return Module(node, "", docstring, imports, attrs, classes, functions, "", "") def get_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" - if name in CACHE_MODULE: + if name in CACHE_MODULE: # TODO: reload return CACHE_MODULE[name] if node := _get_module_node(name): CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. @@ -452,6 +465,7 @@ def get_module(name: str) -> Module | None: CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. module.name = name module.source = _get_module_source(name) # Set from a global cache. + module.kind = "package" if is_package(name) else "module" _postprocess(module) CACHE_MODULE[name] = module return module diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py new file mode 100644 index 00000000..4187d079 --- /dev/null +++ b/src/mkapi/pages.py @@ -0,0 +1,90 @@ +"""Page class that works with other converter.""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from mkapi.converter import convert_html, convert_object +from mkapi.filter import split_filters, update_filters +from mkapi.link import resolve_link + +# from mkapi.core import postprocess +# from mkapi.core.base import Base, Section +# from mkapi.core.code import Code, get_code +# from mkapi.core.inherit import inherit +# from mkapi.core.node import Node, get_node + +if TYPE_CHECKING: + from collections.abc import Iterator + + +MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) +pattern = r"(.*?)" +OBJECT_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) + + +@dataclass(repr=False) +class Page: + """Page class works with [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). + + Args: + source (str): Markdown source. + abs_src_path: Absolute source path of Markdown. + abs_api_paths: A list of API paths. + + Attributes: + markdown: Converted Markdown including API documentation. + nodes: A list of Node instances. + """ + + source: str + abs_src_path: str + abs_api_paths: list[str] = field(default_factory=list) + filters: list[str] = field(default_factory=list) + headings: list[tuple[str, int]] = field(default_factory=list, init=False) + + def convert_markdown(self) -> str: # noqa: D102 + return "\n\n".join(self._iter_markdown()) + + def _resolve_link(self, markdown: str) -> str: + return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) + + def _iter_markdown(self) -> Iterator[str]: + cursor = 0 + for match in MKAPI_PATTERN.finditer(self.source): + start, end = match.start(), match.end() + if cursor < start and (markdown := self.source[cursor:start].strip()): + yield self._resolve_link(markdown) + cursor = end + heading, name = match.groups() + level = len(heading) + name, filters = split_filters(name) + filters = update_filters(self.filters, filters) + markdown = convert_object(name, level) # TODO: callback for link. + if level: + self.headings.append((name, level)) # duplicated name? + yield wrap_markdown(name, markdown, filters) + if cursor < len(self.source) and (markdown := self.source[cursor:].strip()): + yield self._resolve_link(markdown) + + def convert_html(self, html: str) -> str: + """Return modified HTML to [MkAPIPlugin][mkapi.plugins.MkAPIPlugin]. + + Args: + html: Input HTML converted by MkDocs. + """ + + def replace(match: re.Match) -> str: + name = match.group(1) + filters = match.group(2).split("|") + html = match.group(3) + return convert_html(name, html, filters) + + return re.sub(OBJECT_PATTERN, replace, html) + + +def wrap_markdown(name: str, markdown: str, filters: list[str] | None = None) -> str: + """Return Markdown text with marker for object.""" + fs = "|".join(filters) if filters else "" + return f"\n\n{markdown}\n\n" diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index c91a98fe..42dbefb3 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -5,13 +5,10 @@ """ from __future__ import annotations -import atexit import importlib -import inspect import logging import os import re -import shutil import sys from pathlib import Path from typing import TYPE_CHECKING, TypeGuard @@ -20,7 +17,6 @@ from mkdocs.config import config_options from mkdocs.config.base import Config from mkdocs.config.defaults import MkDocsConfig -from mkdocs.livereload import LiveReloadServer from mkdocs.plugins import BasePlugin from mkdocs.structure.files import Files, get_files from mkdocs.structure.nav import Navigation @@ -28,9 +24,9 @@ from mkdocs.structure.toc import AnchorLink, TableOfContents from mkdocs.utils.templates import TemplateContext -import mkapi.config +import mkapi +from mkapi import converter from mkapi.filter import split_filters, update_filters -from mkapi.objects import Module, get_module from mkapi.utils import find_submodule_names, is_package if TYPE_CHECKING: @@ -40,7 +36,7 @@ # from mkapi.core.module import Module, get_module # from mkapi.core.object import get_object -# from mkapi.core.page import Page as MkAPIPage +from mkapi.pages import Page as MkAPIPage logger = logging.getLogger("mkdocs") @@ -70,69 +66,54 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: config.markdown_extensions.append("admonition") return _on_config_plugin(config, self) - # def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: # noqa: ARG002 - # """Collect plugin CSS/JavaScript and appends them to `files`.""" - # root = Path(mkapi.__file__).parent / "theme" - # docs_dir = config.docs_dir - # config.docs_dir = root.as_posix() - # theme_files = get_files(config) - # config.docs_dir = docs_dir - # theme_name = config.theme.name or "mkdocs" - - # css = [] - # js = [] - # for file in theme_files: - # path = Path(file.src_path).as_posix() - # if path.endswith(".css"): - # if "common" in path or theme_name in path: - # files.append(file) - # css.append(path) - # elif path.endswith(".js"): - # files.append(file) - # js.append(path) - # elif path.endswith(".yml"): - # with (root / path).open() as f: - # data = yaml.safe_load(f) - # css = data.get("extra_css", []) + css - # js = data.get("extra_javascript", []) + js - # css = [x for x in css if x not in config.extra_css] - # js = [x for x in js if x not in config.extra_javascript] - # config.extra_css.extend(css) - # config.extra_javascript.extend(js) - - # return files - - # def on_page_markdown( - # self, - # markdown: str, - # page: MkDocsPage, - # config: MkDocsConfig, # noqa: ARG002 - # files: Files, # noqa: ARG002 - # **kwargs, # noqa: ARG002 - # ) -> str: - # """Convert Markdown source to intermidiate version.""" - # abs_src_path = page.file.abs_src_path - # clean_page_title(page) - # abs_api_paths = self.config.abs_api_paths - # filters = self.config.filters - # mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) - # self.config.pages[abs_src_path] = mkapi_page - # return mkapi_page.markdown - - # def on_page_content( - # self, - # html: str, - # page: MkDocsPage, - # config: MkDocsConfig, # noqa: ARG002 - # files: Files, # noqa: ARG002 - # **kwargs, # noqa: ARG002 - # ) -> str: - # """Merge HTML and MkAPI's node structure.""" - # if page.title: - # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore - # abs_src_path = page.file.abs_src_path - # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - # return mkapi_page.content(html) + def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: + """Collect plugin CSS/JavaScript and appends them to `files`.""" + root = Path(mkapi.__file__).parent / "themes" + docs_dir = config.docs_dir + config.docs_dir = root.as_posix() + theme_files = get_files(config) + config.docs_dir = docs_dir + theme_name = config.theme.name or "mkdocs" + + css = [] + js = [] + for file in theme_files: + path = Path(file.src_path).as_posix() + if path.endswith(".css"): + if "common" in path or theme_name in path: + files.append(file) + css.append(path) + elif path.endswith(".js"): + files.append(file) + js.append(path) + elif path.endswith(".yml"): + with (root / path).open() as f: + data = yaml.safe_load(f) + css = data.get("extra_css", []) + css + js = data.get("extra_javascript", []) + js + css = [x for x in css if x not in config.extra_css] + js = [x for x in js if x not in config.extra_javascript] + config.extra_css.extend(css) + config.extra_javascript.extend(js) + + return files + + def on_page_markdown(self, markdown: str, page: MkDocsPage, **kwargs) -> str: + """Convert Markdown source to intermidiate version.""" + # clean_page_title(page) + abs_src_path = page.file.abs_src_path + abs_api_paths = self.config.abs_api_paths + filters = self.config.filters + mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) + self.config.pages[abs_src_path] = mkapi_page + return mkapi_page.convert_markdown() + + def on_page_content(self, html: str, page: MkDocsPage, **kwargs) -> str: + """Merge HTML and MkAPI's object structure.""" + # if page.title: + # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore + mkapi_page: MkAPIPage = self.config.pages[page.file.abs_src_path] + return mkapi_page.convert_html(html) # def on_page_context( # self, @@ -248,9 +229,6 @@ def _create_nav( tree.append(callback(sub, depth, False)) # noqa: FBT003 continue subtree = _create_nav(sub, callback, section, predicate, depth + 1) - # if (n := len(subtree)) == 1: - # tree.extend(subtree) - # elif n > 1: if len(subtree): title = section(sub, depth) if section else sub tree.append({title: subtree}) @@ -288,7 +266,8 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: module_path = name + ".md" abs_module_path = abs_api_path / module_path abs_api_paths.append(abs_module_path) - _create_page(name, abs_module_path, filters) + with abs_module_path.open("w") as f: + f.write(converter.convert_module(name, filters)) nav_path = (Path(api_path) / module_path).as_posix() title = page_title(name, depth, ispackage) if page_title else name return {title: nav_path} @@ -298,12 +277,6 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: return nav, abs_api_paths -def _create_page(name: str, path: Path, filters: list[str]) -> None: - """Create a page.""" - with path.open("w") as f: - f.write(f"# {name}") - - def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: if func := _get_function(plugin, "on_config"): msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" @@ -314,30 +287,23 @@ def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig return config -# def create_source_page(path: Path, module: Module, filters: list[str]) -> None: -# """Create a page for source.""" -# filters_str = "|".join(filters) -# with path.open("w") as f: -# f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") - - -# def clear_prefix( -# toc: TableOfContents | list[AnchorLink], -# level: int, -# id_: str = "", -# ) -> None: -# """Clear prefix.""" -# for toc_item in toc: -# if toc_item.level >= level and (not id_ or toc_item.title == id_): -# toc_item.title = toc_item.title.split(".")[-1] -# clear_prefix(toc_item.children, level) +def _clear_prefix( + toc: TableOfContents | list[AnchorLink], + name: str, + level: int, +) -> None: + """Clear prefix.""" + for toc_item in toc: + if toc_item.level >= level and (not name or toc_item.title == name): + toc_item.title = toc_item.title.split(".")[-1] + _clear_prefix(toc_item.children, "", level) -# def clean_page_title(page: MkDocsPage) -> None: -# """Clean page title.""" -# title = str(page.title) -# if title.startswith("![mkapi]("): -# page.title = title[9:-1].split("|")[0] # type: ignore +def _clean_page_title(page: MkDocsPage) -> None: + """Clean page title.""" + title = str(page.title) + if title.startswith("![mkapi]("): + page.title = title[9:-1].split("|")[0] # type: ignore # def _rmtree(path: Path) -> None: @@ -349,3 +315,9 @@ def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig # except PermissionError: # msg = f"[MkAPI] Couldn't delete directory: {path}" # logger.warning(msg) + +# def create_source_page(path: Path, module: Module, filters: list[str]) -> None: +# """Create a page for source.""" +# filters_str = "|".join(filters) +# with path.open("w") as f: +# f.write(f"# ![mkapi]({module.object.id}|code|{filters_str})") diff --git a/src/mkapi/renderer.py b/src/mkapi/renderer.py new file mode 100644 index 00000000..025c48fd --- /dev/null +++ b/src/mkapi/renderer.py @@ -0,0 +1,172 @@ +"""Renderer class.""" +import os +from dataclasses import dataclass, field +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, Template, select_autoescape + +import mkapi +from mkapi.objects import Module + + +@dataclass +class Renderer: + """Render [Object] instance recursively to create API documentation. + + Attributes: + templates: Jinja template dictionary. + """ + + templates: dict[str, Template] = field(default_factory=dict, init=False) + + def __post_init__(self) -> None: + path = Path(mkapi.__file__).parent / "templates" + loader = FileSystemLoader(path) + env = Environment(loader=loader, autoescape=select_autoescape(["jinja2"])) + for name in os.listdir(path): + template = env.get_template(name) + self.templates[Path(name).stem] = template + + def render_module(self, module: Module, filters: list[str] | None = None) -> str: + """Return a rendered Markdown for Module. + + Args: + module: Module instance. + filters: A list of filters. Avaiable filters: `inherit`, `strict`, + `heading`. + + Note: + This function returns Markdown instead of HTML. The returned Markdown + will be converted into HTML by MkDocs. Then the HTML is rendered into HTML + again by other functions in this module. + """ + filters = filters if filters else [] + module_filter = object_filter = "" + if filters: + object_filter = "|" + "|".join(filters) + template = self.templates["module"] + return template.render( + module=module, + module_filter=module_filter, + object_filter=object_filter, + ) + + # def render(self, node: Node, filters: list[str] | None = None) -> str: + # """Return a rendered HTML for Node. + + # Args: + # node: Node instance. + # filters: Filters. + # """ + # obj = self.render_object(node.object, filters=filters) + # docstring = self.render_docstring(node.docstring, filters=filters) + # members = [self.render(member, filters) for member in node.members] + # return self.render_node(node, obj, docstring, members) + + # def render_node( + # self, + # node: Node, + # obj: str, + # docstring: str, + # members: list[str], + # ) -> str: + # """Return a rendered HTML for Node using prerendered components. + + # Args: + # node: Node instance. + # obj: Rendered HTML for Object instance. + # docstring: Rendered HTML for Docstring instance. + # members: A list of rendered HTML for member Node instances. + # """ + # template = self.templates["node"] + # return template.render( + # node=node, + # object=obj, + # docstring=docstring, + # members=members, + # ) + + # def render_object(self, obj: Object, filters: list[str] | None = None) -> str: + # """Return a rendered HTML for Object. + + # Args: + # obj: Object instance. + # filters: Filters. + # """ + # filters = filters if filters else [] + # context = link.resolve_object(obj.html) + # level = context.get("level") + # if level: + # if obj.kind in ["module", "package"]: + # filters.append("plain") + # elif "plain" in filters: + # del filters[filters.index("plain")] + # tag = f"h{level}" + # else: + # tag = "div" + # template = self.templates["object"] + # return template.render(context, object=obj, tag=tag, filters=filters) + + # def render_object_member( + # self, + # name: str, + # url: str, + # signature: dict[str, Any], + # ) -> str: + # """Return a rendered HTML for Object in toc. + + # Args: + # name: Object name. + # url: Link to definition. + # signature: Signature. + # """ + # template = self.templates["member"] + # return template.render(name=name, url=url, signature=signature) + + # def render_docstring( + # self, + # docstring: Docstring, + # filters: list[str] | None = None, + # ) -> str: + # """Return a rendered HTML for Docstring. + + # Args: + # docstring: Docstring instance. + # filters: Filters. + # """ + # if not docstring: + # return "" + # template = self.templates["docstring"] + # for section in docstring.sections: + # if section.items: + # valid = any(item.description for item in section.items) + # if filters and "strict" in filters or section.name == "Bases" or valid: + # section.html = self.render_section(section, filters) + # return template.render(docstring=docstring) + + # def render_section(self, section: Section, filters: list[str] | None = None) -> str: + # """Return a rendered HTML for Section. + + # Args: + # section: Section instance. + # filters: Filters. + # """ + # filters = filters if filters else [] + # if section.name == "Bases": + # return self.templates["bases"].render(section=section) + # return self.templates["items"].render(section=section, filters=filters) + + # def render_code(self, code: Code, filters: list[str] | None = None) -> str: + # """Return a rendered Markdown for source code. + + # Args: + # code: Code instance. + # filters: Filters. + # """ + # filters = filters if filters else [] + # template = self.templates["code"] + # return template.render(code=code, module=code.module, filters=filters) + + +#: Renderer instance that is used globally. +renderer: Renderer = Renderer() diff --git a/src/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 index 5bdf645a..f2a9ea4d 100644 --- a/src/mkapi/templates/module.jinja2 +++ b/src/mkapi/templates/module.jinja2 @@ -1,17 +1,17 @@ -# ![mkapi]({{ module.object.id }}{{ module_filter }}|plain|link|sourcelink) +# ![mkapi]({{ module.name }}{{ module_filter }}|plain|link|sourcelink) -{% if module.object.kind == 'module' -%} -{% for node in module.node.members -%} +{% if module.kind == 'module' -%} +{% for member in module.members -%} {% if 'noheading' in object_filter -%} -![mkapi]({{ node.object.id }}{{ object_filter }}|link|sourcelink) +![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) {% else -%} -## ![mkapi]({{ node.object.id }}{{ object_filter }}|link|sourcelink) +## ![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) {% endif -%} {% endfor -%} {% else -%} {% for member in module.members -%} {% if member.docstring -%} -## ![mkapi]({{ member.object.id }}{{ module_filter }}|plain|apilink|sourcelink) +## ![mkapi]({{ member.id }}{{ module_filter }}|plain|apilink|sourcelink) {% endif -%} {% endfor -%} {% endif -%} diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 0827a2a8..733b542b 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -8,7 +8,6 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator - from typing import Literal def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: diff --git a/src_old/mkapi/core/link.py b/src_old/mkapi/core/link.py deleted file mode 100644 index 8e93cedd..00000000 --- a/src_old/mkapi/core/link.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Provide functions that relate to linking functionality.""" -import os -import re -import warnings -from html.parser import HTMLParser -from pathlib import Path -from typing import Any - -from mkapi.core.object import get_fullname - -LINK_PATTERN = re.compile(r"\[(.+?)\]\((.+?)\)") -REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") - - -def link(name: str, href: str) -> str: - """Return Markdown link with a mark that indicates this link was created by MkAPI. - - Args: - name: Link name. - href: Reference. - - Examples: - >>> link('abc', 'xyz') - '[abc](!xyz)' - """ - return f"[{name}](!{href})" - - -def get_link(obj: type, *, include_module: bool = False) -> str: - """Return Markdown link for object, if possible. - - Args: - obj: Object - include_module: If True, link text includes module path. - - Examples: - >>> get_link(int) - 'int' - >>> get_link(get_fullname) - '[get_fullname](!mkapi.core.object.get_fullname)' - >>> get_link(get_fullname, include_module=True) - '[mkapi.core.object.get_fullname](!mkapi.core.object.get_fullname)' - """ - if hasattr(obj, "__qualname__"): - name = obj.__qualname__ - elif hasattr(obj, "__name__"): - name = obj.__name__ - else: - msg = f"obj has no name: {obj}" - warnings.warn(msg, stacklevel=1) - return str(obj) - - if not hasattr(obj, "__module__") or (module := obj.__module__) == "builtins": - return name - fullname = f"{module}.{name}" - text = fullname if include_module else name - if obj.__name__.startswith("_"): - return text - return link(text, fullname) - - -def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> str: - """Reutrn resolved link. - - Args: - markdown: Markdown source. - abs_src_path: Absolute source path of Markdown. - abs_api_paths: List of API paths. - - Examples: - >>> abs_src_path = '/src/examples/example.md' - >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] - >>> resolve_link('[abc](!b.c.d)', abs_src_path, abs_api_paths) - '[abc](../../api/b.c#b.c.d)' - """ - - def replace(match: re.Match) -> str: - name, href = match.groups() - if href.startswith("!!"): # Just for MkAPI documentation. - href = href[2:] - return f"[{name}]({href})" - if href.startswith("!"): - href = href[1:] - from_mkapi = True - else: - from_mkapi = False - - href = _resolve_href(href, abs_src_path, abs_api_paths) - if href: - return f"[{name}]({href})" - if from_mkapi: - return name - return match.group() - - return re.sub(LINK_PATTERN, replace, markdown) - - -def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: - if not name: - return "" - abs_api_path = _match_last(name, abs_api_paths) - if not abs_api_path: - return "" - relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) - relpath = relpath.replace("\\", "/") - return f"{relpath}#{name}" - - -def _match_last(name: str, abs_api_paths: list[str]) -> str: - match = "" - for abs_api_path in abs_api_paths: - _, path = os.path.split(abs_api_path) - if name.startswith(path[:-3]): - match = abs_api_path - return match - - -class _ObjectParser(HTMLParser): - def feed(self, html: str) -> dict[str, Any]: - self.context = {"href": [], "heading_id": ""} - super().feed(html) - href = self.context["href"] - if len(href) == 2: # noqa: PLR2004 - prefix_url, name_url = href - elif len(href) == 1: - prefix_url, name_url = "", href[0] - else: - prefix_url, name_url = "", "" - self.context["prefix_url"] = prefix_url - self.context["name_url"] = name_url - del self.context["href"] - return self.context - - def handle_starttag(self, tag: str, attrs: list[str]) -> None: - context = self.context - if tag == "p": - context["level"] = 0 - elif re.match(r"h[1-6]", tag): - context["level"] = int(tag[1:]) - for attr in attrs: - if attr[0] == "id": - self.context["heading_id"] = attr[1] - elif tag == "a": - for attr in attrs: - if attr[0] == "href": - href = attr[1] - if href.startswith("./"): - href = href[2:] - self.context["href"].append(href) - - -parser = _ObjectParser() - - -def resolve_object(html: str) -> dict[str, Any]: - """Reutrn an object context dictionary. - - Args: - html: HTML source. - - Examples: - >>> resolve_object("

pn

") - {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} - >>> resolve_object("

pn

") - {'heading_id': 'i', 'level': 2, 'prefix_url': 'a', 'name_url': 'b'} - """ - parser.reset() - return parser.feed(html) - - -def replace_link(obj: object, markdown: str) -> str: - """Return a replaced link with object's full name. - - Args: - obj: Object that has a module. - markdown: Markdown - - Examples: - >>> from mkapi.core.object import get_object - >>> obj = get_object('mkapi.core.structure.Object') - >>> replace_link(obj, '[Signature]()') - '[Signature](!mkapi.inspect.signature.Signature)' - >>> replace_link(obj, '[](Signature)') - '[Signature](!mkapi.inspect.signature.Signature)' - >>> replace_link(obj, '[text](Signature)') - '[text](!mkapi.inspect.signature.Signature)' - >>> replace_link(obj, '[dummy.Dummy]()') - '[dummy.Dummy]()' - >>> replace_link(obj, 'Signature_') - '[Signature](!mkapi.inspect.signature.Signature)' - """ - - def replace(match: re.Match) -> str: - text, name, rest = match.groups() - if rest: - name, text = rest, "" - elif not name: - name, text = text, "" - fullname = get_fullname(obj, name) - if fullname == "": - return match.group() - return link(text or name, fullname) - - return re.sub(REPLACE_LINK_PATTERN, replace, markdown) diff --git a/src_old/mkapi/core/page.py b/src_old/mkapi/core/page.py deleted file mode 100644 index 2467b7fb..00000000 --- a/src_old/mkapi/core/page.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Page class that works with other converter.""" -import re -from collections.abc import Iterator -from dataclasses import InitVar, dataclass, field - -from mkapi.core import postprocess -from mkapi.core.base import Base, Section -from mkapi.core.code import Code, get_code -from mkapi.core.filter import split_filters, update_filters -from mkapi.core.inherit import inherit -from mkapi.core.link import resolve_link -from mkapi.core.node import Node, get_node - -MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) -pattern = r"(.*?)" -NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) - - -@dataclass -class Page: - """Page class works with [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). - - Args: - source (str): Markdown source. - abs_src_path: Absolute source path of Markdown. - abs_api_paths: A list of API paths. - - Attributes: - markdown: Converted Markdown including API documentation. - nodes: A list of Node instances. - """ - - source: InitVar[str] - abs_src_path: str - abs_api_paths: list[str] = field(default_factory=list, repr=False) - filters: list[str] = field(default_factory=list, repr=False) - markdown: str = field(init=False, repr=False) - nodes: list[Node | Code] = field(default_factory=list, init=False, repr=False) - headings: list[tuple[int, str]] = field( - default_factory=list, - init=False, - repr=False, - ) - - def __post_init__(self, source: str) -> None: - self.markdown = "\n\n".join(self.split(source)) - - def resolve_link(self, markdown: str) -> str: # noqa: D102 - return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) - - def resolve_link_from_base(self, base: Base) -> str: # noqa: D102 - if isinstance(base, Section) and base.name in ["Example", "Examples"]: - return base.markdown - return resolve_link(base.markdown, self.abs_src_path, self.abs_api_paths) - - def split(self, source: str) -> Iterator[str]: # noqa: D102 - callback = self.resolve_link_from_base - cursor = index = 0 - for match in MKAPI_PATTERN.finditer(source): - start, end = match.start(), match.end() - if cursor < start: - markdown = source[cursor:start].strip() - if markdown: - yield self.resolve_link(markdown) - cursor = end - heading, name = match.groups() - level = len(heading) - name, filters = split_filters(name) - if not name: - self.filters = filters - continue - filters = update_filters(self.filters, filters) - if "code" in filters: - code = get_code(name) - self.nodes.append(code) - markdown = code.get_markdown(level) - else: - node = get_node(name) - inherit(node) - postprocess.transform(node, filters) - self.nodes.append(node) - markdown = node.get_markdown(level, callback=callback) - if level: - self.headings.append((level, node.object.id)) - yield node_markdown(index, markdown, filters) - index += 1 - if cursor < len(source): - markdown = source[cursor:].strip() - if markdown: - yield self.resolve_link(markdown) - - def content(self, html: str) -> str: - """Return modified HTML to [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). - - Args: - html: Input HTML converted by MkDocs. - """ - - def replace(match: re.Match) -> str: - node = self.nodes[int(match.group(1))] - filters = match.group(2).split("|") - node.set_html(match.group(3)) - return node.get_html(filters) - - return re.sub(NODE_PATTERN, replace, html) - - -def node_markdown(index: int, markdown: str, filters: list[str] | None = None) -> str: - """Return Markdown text for node.""" - fs = "|".join(filters) if filters else "" - return f"\n\n{markdown}\n\n" diff --git a/src_old/mkapi/core/renderer.py b/src_old/mkapi/core/renderer.py deleted file mode 100644 index f9a386c0..00000000 --- a/src_old/mkapi/core/renderer.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Renderer class that renders Node instance to create API documentation.""" -import os -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from jinja2 import Environment, FileSystemLoader, Template, select_autoescape - -import mkapi -from mkapi.core import link -from mkapi.core.base import Docstring, Section -from mkapi.core.code import Code -from mkapi.core.module import Module -from mkapi.core.node import Node -from mkapi.core.structure import Object - - -@dataclass -class Renderer: - """Renderer instance renders Node instance recursively to create API documentation. - - Attributes: - templates: Jinja template dictionary. - """ - - templates: dict[str, Template] = field(default_factory=dict, init=False) - - def __post_init__(self) -> None: - path = Path(mkapi.__file__).parent / "templates" - loader = FileSystemLoader(path) - env = Environment(loader=loader, autoescape=select_autoescape(["jinja2"])) - for name in os.listdir(path): - template = env.get_template(name) - self.templates[Path(name).stem] = template - - def render(self, node: Node, filters: list[str] | None = None) -> str: - """Return a rendered HTML for Node. - - Args: - node: Node instance. - filters: Filters. - """ - obj = self.render_object(node.object, filters=filters) - docstring = self.render_docstring(node.docstring, filters=filters) - members = [self.render(member, filters) for member in node.members] - return self.render_node(node, obj, docstring, members) - - def render_node( - self, - node: Node, - obj: str, - docstring: str, - members: list[str], - ) -> str: - """Return a rendered HTML for Node using prerendered components. - - Args: - node: Node instance. - obj: Rendered HTML for Object instance. - docstring: Rendered HTML for Docstring instance. - members: A list of rendered HTML for member Node instances. - """ - template = self.templates["node"] - return template.render( - node=node, - object=obj, - docstring=docstring, - members=members, - ) - - def render_object(self, obj: Object, filters: list[str] | None = None) -> str: - """Return a rendered HTML for Object. - - Args: - obj: Object instance. - filters: Filters. - """ - filters = filters if filters else [] - context = link.resolve_object(obj.html) - level = context.get("level") - if level: - if obj.kind in ["module", "package"]: - filters.append("plain") - elif "plain" in filters: - del filters[filters.index("plain")] - tag = f"h{level}" - else: - tag = "div" - template = self.templates["object"] - return template.render(context, object=obj, tag=tag, filters=filters) - - def render_object_member( - self, - name: str, - url: str, - signature: dict[str, Any], - ) -> str: - """Return a rendered HTML for Object in toc. - - Args: - name: Object name. - url: Link to definition. - signature: Signature. - """ - template = self.templates["member"] - return template.render(name=name, url=url, signature=signature) - - def render_docstring( - self, - docstring: Docstring, - filters: list[str] | None = None, - ) -> str: - """Return a rendered HTML for Docstring. - - Args: - docstring: Docstring instance. - filters: Filters. - """ - if not docstring: - return "" - template = self.templates["docstring"] - for section in docstring.sections: - if section.items: - valid = any(item.description for item in section.items) - if filters and "strict" in filters or section.name == "Bases" or valid: - section.html = self.render_section(section, filters) - return template.render(docstring=docstring) - - def render_section(self, section: Section, filters: list[str] | None = None) -> str: - """Return a rendered HTML for Section. - - Args: - section: Section instance. - filters: Filters. - """ - filters = filters if filters else [] - if section.name == "Bases": - return self.templates["bases"].render(section=section) - return self.templates["items"].render(section=section, filters=filters) - - def render_module(self, module: Module, filters: list[str] | None = None) -> str: - """Return a rendered Markdown for Module. - - Args: - module: Module instance. - filters: A list of filters. Avaiable filters: `upper`, `inherit`, - `strict`, `heading`. - - Note: - This function returns Markdown instead of HTML. The returned Markdown - will be converted into HTML by MkDocs. Then the HTML is rendered into HTML - again by other functions in this module. - """ - filters = filters if filters else [] - module_filter = "" - if "upper" in filters: - module_filter = "|upper" - filters = filters.copy() - del filters[filters.index("upper")] - object_filter = "|" + "|".join(filters) - template = self.templates["module"] - return template.render( - module=module, - module_filter=module_filter, - object_filter=object_filter, - ) - - def render_code(self, code: Code, filters: list[str] | None = None) -> str: - """Return a rendered Markdown for source code. - - Args: - code: Code instance. - filters: Filters. - """ - filters = filters if filters else [] - template = self.templates["code"] - return template.render(code=code, module=code.module, filters=filters) - - -#: Renderer instance that is used globally. -renderer: Renderer = Renderer() diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index faeb9949..991f8f0e 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -92,6 +92,27 @@ def test_get_module_source(): module = get_module("mkapi.plugins") assert module cls = module.get("MkAPIConfig") + assert cls assert cls.get_module() is module - assert cls.get_source().startswith("class MkAPIConfig") + src = cls.get_source() + assert src + assert src.startswith("class MkAPIConfig") assert "MkAPIPlugin" in module.get_source() + + +def test_module_kind(): + module = get_module("mkdocs") + assert module + assert module.kind == "package" + module = get_module("mkdocs.plugins") + assert module + assert module.kind == "module" + + +def test_property(): + module = get_module("mkapi.objects") + assert module + assert module.id == "mkapi.objects" + f = module.get_function("get_object") + assert f + assert f.id == "mkapi.objects.get_object" diff --git a/tests/utils/__init__.py b/tests/pages/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/pages/__init__.py diff --git a/tests/pages/test_page.py b/tests/pages/test_page.py new file mode 100644 index 00000000..a875cd4a --- /dev/null +++ b/tests/pages/test_page.py @@ -0,0 +1,54 @@ +from markdown import Markdown + +from mkapi.pages import Page + +source = """ +# Title + +## ![mkapi](a.o.Object|a|b) + +text + +### ![mkapi](a.s.Section) + +end +""" + + +def test_page(): + abs_src_path = "/examples/docs/tutorial/index.md" + abs_api_paths = [ + "/examples/docs/api/a.o.md", + "/examples/docs/api/a.s.md", + ] + page = Page(source, abs_src_path, abs_api_paths) + m = page.convert_markdown() + print(m) + # assert m.startswith("# Title\n") + # assert "" in m + # assert "## [a.o](../api/a.o.md#mkapi.core).[base]" in m + # assert "" in m + # assert "[mkapi.core.base](../api/mkapi.core.base.md#mkapi.core.base)." in m + # assert "[Base](../api/mkapi.core.base.md#mkapi.core.base.Base)" in m + # assert "\n###" in m + # assert "\n####" in m + # assert m.endswith("end") + + converter = Markdown() + h = page.convert_html(converter.convert(m)) + assert "

Title

" in h + print("-" * 40) + print(h) + assert 0 + # assert '
' in h + # assert 'MKAPI.CORE' in h + # assert 'BASE' in h + # assert 'Base' in h + # assert '
' in h + # assert '
' in h + # assert 'mkapi.core.base' in h + # assert 'Base' in h + # assert 'Attributes' in h + # assert 'Methods' in h + # assert 'Classes' in h + # assert "

end

" in h diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 00000000..7d947d86 --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,38 @@ +# import typing + +# from mkapi.link import get_link, resolve_link, resolve_object + + +# def test_get_link_private(): +# class A: +# def func(self): +# pass + +# def _private(self): +# pass + +# q = "test_get_link_private..A" +# m = "test_link" +# assert get_link(A) == f"[{q}](!{m}.{q})" +# assert get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" +# assert get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore +# assert get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 + + +# def test_get_link_typing(): +# assert get_link(typing.Self) == "[Self](!typing.Self)" + + +# def test_resolve_link(): +# assert resolve_link("[A](!!a)", "", []) == "[A](a)" +# assert resolve_link("[A](!a)", "", []) == "A" +# assert resolve_link("[A](a)", "", []) == "[A](a)" + + +# def test_resolve_object(): +# html = "

p

" +# context = resolve_object(html) +# assert context == {"heading_id": "", "level": 0, "prefix_url": "", "name_url": "a"} +# html = "

pn

" +# context = resolve_object(html) +# assert context == {"heading_id": "", "level": 0, "prefix_url": "a", "name_url": "b"} diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 00000000..53bb555d --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,28 @@ +from mkapi.objects import get_module +from mkapi.renderer import renderer + + +def test_render_module(google): + markdown = renderer.render_module(google) + assert "# ![mkapi](examples.styles.example_google" in markdown + assert "## ![mkapi](examples.styles.example_google.ExampleClass" in markdown + assert "## ![mkapi](examples.styles.example_google.example_generator" in markdown + + +# def test_module_empty_filters(): +# module = get_module("mkapi.core.base") +# m = renderer.render_module(module).split("\n") +# assert m[0] == "# ![mkapi](mkapi.core.base|plain|link|sourcelink)" +# assert m[2] == "## ![mkapi](mkapi.core.base.Base||link|sourcelink)" +# assert m[3] == "## ![mkapi](mkapi.core.base.Inline||link|sourcelink)" +# assert m[4] == "## ![mkapi](mkapi.core.base.Type||link|sourcelink)" +# assert m[5] == "## ![mkapi](mkapi.core.base.Item||link|sourcelink)" + + +# def test_code_empty_filters(): +# code = get_code("mkapi.core.base") +# m = renderer.render_code(code) +# assert 'mkapi.core.' in m +# assert 'base' in m +# assert '' in m +# assert 'DOCS' in m diff --git a/tests/utils/test_module.py b/tests/test_utils.py similarity index 100% rename from tests/utils/test_module.py rename to tests/test_utils.py diff --git a/tests_old/core/test_core_link.py b/tests_old/core/test_core_link.py deleted file mode 100644 index abed6fa1..00000000 --- a/tests_old/core/test_core_link.py +++ /dev/null @@ -1,38 +0,0 @@ -import typing - -from mkapi.core.link import get_link, resolve_link, resolve_object - - -def test_get_link_private(): - class A: - def func(self): - pass - - def _private(self): - pass - - q = "test_get_link_private..A" - m = "test_core_link" - assert get_link(A) == f"[{q}](!{m}.{q})" - assert get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" - assert get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore - assert get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 - - -def test_get_link_typing(): - assert get_link(typing.Self) == "[Self](!typing.Self)" - - -def test_resolve_link(): - assert resolve_link("[A](!!a)", "", []) == "[A](a)" - assert resolve_link("[A](!a)", "", []) == "A" - assert resolve_link("[A](a)", "", []) == "[A](a)" - - -def test_resolve_object(): - html = "

p

" - context = resolve_object(html) - assert context == {"heading_id": "", "level": 0, "prefix_url": "", "name_url": "a"} - html = "

pn

" - context = resolve_object(html) - assert context == {"heading_id": "", "level": 0, "prefix_url": "a", "name_url": "b"} diff --git a/tests_old/core/test_core_page.py b/tests_old/core/test_core_page.py deleted file mode 100644 index 2676673b..00000000 --- a/tests_old/core/test_core_page.py +++ /dev/null @@ -1,50 +0,0 @@ -from markdown import Markdown - -from mkapi.core.page import Page - -source = """ -# Title - -## ![mkapi](mkapi.core.base|upper|link) - -text - -### ![mkapi](mkapi.core.base.Base|strict) - -end -""" - - -def test_page(): - abs_src_path = "/examples/docs/tutorial/index.md" - abs_api_paths = [ - "/examples/docs/api/mkapi.core.md", - "/examples/docs/api/mkapi.core.base.md", - ] - page = Page(source, abs_src_path, abs_api_paths) - m = page.markdown - assert m.startswith("# Title\n") - assert "" in m - assert "## [mkapi.core](../api/mkapi.core.md#mkapi.core).[base]" in m - assert "" in m - assert "[mkapi.core.base](../api/mkapi.core.base.md#mkapi.core.base)." in m - assert "[Base](../api/mkapi.core.base.md#mkapi.core.base.Base)" in m - assert "\n###" in m - assert "\n####" in m - assert m.endswith("end") - - converter = Markdown() - h = page.content(converter.convert(m)) - assert "

Title

" in h - assert '
' in h - assert 'MKAPI.CORE' in h - assert 'BASE' in h - assert 'Base' in h - assert '
' in h - assert '
' in h - assert 'mkapi.core.base' in h - assert 'Base' in h - assert 'Attributes' in h - assert 'Methods' in h - assert 'Classes' in h - assert "

end

" in h diff --git a/tests_old/core/test_core_renderer.py b/tests_old/core/test_core_renderer.py deleted file mode 100644 index 075eca48..00000000 --- a/tests_old/core/test_core_renderer.py +++ /dev/null @@ -1,22 +0,0 @@ -from mkapi.core.code import get_code -from mkapi.core.module import get_module -from mkapi.core.renderer import renderer - - -def test_module_empty_filters(): - module = get_module("mkapi.core.base") - m = renderer.render_module(module).split("\n") - assert m[0] == "# ![mkapi](mkapi.core.base|plain|link|sourcelink)" - assert m[2] == "## ![mkapi](mkapi.core.base.Base||link|sourcelink)" - assert m[3] == "## ![mkapi](mkapi.core.base.Inline||link|sourcelink)" - assert m[4] == "## ![mkapi](mkapi.core.base.Type||link|sourcelink)" - assert m[5] == "## ![mkapi](mkapi.core.base.Item||link|sourcelink)" - - -def test_code_empty_filters(): - code = get_code("mkapi.core.base") - m = renderer.render_code(code) - assert 'mkapi.core.' in m - assert 'base' in m - assert '' in m - assert 'DOCS' in m From 46d571a64e2e12026cf01d2ae0a3c1fac1d21ad3 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 7 Jan 2024 06:36:00 +0900 Subject: [PATCH 063/148] before node --- src/mkapi/converter.py | 7 +++--- src/mkapi/nodes.py | 7 ++++++ src/mkapi/objects.py | 9 +------- src/mkapi/plugins.py | 45 +++++++++++++++++------------------- src/mkapi/renderer.py | 2 +- tests/nodes/__init__.py | 0 tests/nodes/test_node.py | 8 +++++++ tests/objects/test_object.py | 9 -------- tests/pages/test_page.py | 18 +++++++-------- tests/test_renderer.py | 14 +++++------ 10 files changed, 58 insertions(+), 61 deletions(-) create mode 100644 src/mkapi/nodes.py create mode 100644 tests/nodes/__init__.py create mode 100644 tests/nodes/test_node.py diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py index ac627a5f..b92f61e4 100644 --- a/src/mkapi/converter.py +++ b/src/mkapi/converter.py @@ -2,13 +2,14 @@ from __future__ import annotations from mkapi.objects import get_module -from mkapi.renderer import renderer + +# from mkapi.renderer import renderer def convert_module(name: str, filters: list[str]) -> str: """Convert the [Module] instance to markdown text.""" - if module := get_module(name): - return renderer.render_module(module) + # if module := get_module(name): + # return renderer.render_module(module) return f"{name} not found" diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py new file mode 100644 index 00000000..06bdefc0 --- /dev/null +++ b/src/mkapi/nodes.py @@ -0,0 +1,7 @@ +# @property +# def id(self) -> str: # noqa: D102, A003 +# return self.name + +# @property +# def members(self) -> list[Class | Function]: # noqa: D102 +# return self.classes + self.functions diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index a1d7677d..14f40eac 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -404,14 +404,6 @@ def get_source(self) -> str: """Return the source code.""" return _get_module_source(self.name) if self.name else "" - @property - def id(self) -> str: # noqa: D102, A003 - return self.name - - @property - def members(self) -> list[Class | Function]: # noqa: D102 - return self.classes + self.functions - CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module | None, str]] = {} CACHE_MODULE: dict[str, Module | None] = {} @@ -493,6 +485,7 @@ def get_object(fullname: str) -> Module | ModuleMember | None: return get_object_from_module(name, module) +# a1.b_2(c[d]) -> a1, b_2, c, d SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 42dbefb3..822f1dfe 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -19,11 +19,9 @@ from mkdocs.config.defaults import MkDocsConfig from mkdocs.plugins import BasePlugin from mkdocs.structure.files import Files, get_files -from mkdocs.structure.nav import Navigation -from mkdocs.structure.pages import Page as MkDocsPage -from mkdocs.structure.toc import AnchorLink, TableOfContents -from mkdocs.utils.templates import TemplateContext +# from mkdocs.structure.nav import Navigation +# from mkdocs.utils.templates import TemplateContext import mkapi from mkapi import converter from mkapi.filter import split_filters, update_filters @@ -32,10 +30,9 @@ if TYPE_CHECKING: from collections.abc import Callable -# from mkapi.modules import Module, get_module + from mkdocs.structure.pages import Page as MkDocsPage + from mkdocs.structure.toc import AnchorLink, TableOfContents -# from mkapi.core.module import Module, get_module -# from mkapi.core.object import get_object from mkapi.pages import Page as MkAPIPage logger = logging.getLogger("mkdocs") @@ -115,23 +112,23 @@ def on_page_content(self, html: str, page: MkDocsPage, **kwargs) -> str: mkapi_page: MkAPIPage = self.config.pages[page.file.abs_src_path] return mkapi_page.convert_html(html) - # def on_page_context( - # self, - # context: TemplateContext, - # page: MkDocsPage, - # config: MkDocsConfig, # noqa: ARG002 - # nav: Navigation, # noqa: ARG002 - # **kwargs, # noqa: ARG002 - # ) -> TemplateContext: - # """Clear prefix in toc.""" - # abs_src_path = page.file.abs_src_path - # if abs_src_path in self.config.abs_api_paths: - # clear_prefix(page.toc, 2) - # else: - # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - # for level, id_ in mkapi_page.headings: - # clear_prefix(page.toc, level, id_) - # return context + # def on_page_context( + # self, + # context: TemplateContext, + # page: MkDocsPage, + # config: MkDocsConfig, + # nav: Navigation, + # **kwargs, + # ) -> TemplateContext: + # """Clear prefix in toc.""" + # abs_src_path = page.file.abs_src_path + # if abs_src_path in self.config.abs_api_paths: + # clear_prefix(page.toc, 2) + # else: + # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + # for level, id_ in mkapi_page.headings: + # clear_prefix(page.toc, level, id_) + # return context def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): for path in ["themes", "templates"]: diff --git a/src/mkapi/renderer.py b/src/mkapi/renderer.py index 025c48fd..b3e20ca0 100644 --- a/src/mkapi/renderer.py +++ b/src/mkapi/renderer.py @@ -169,4 +169,4 @@ def render_module(self, module: Module, filters: list[str] | None = None) -> str #: Renderer instance that is used globally. -renderer: Renderer = Renderer() +# renderer: Renderer = Renderer() diff --git a/tests/nodes/__init__.py b/tests/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py new file mode 100644 index 00000000..3079b8e4 --- /dev/null +++ b/tests/nodes/test_node.py @@ -0,0 +1,8 @@ + +# def test_property(): +# module = get_module("mkapi.objects") +# assert module +# assert module.id == "mkapi.objects" +# f = module.get_function("get_object") +# assert f +# assert f.id == "mkapi.objects.get_object" \ No newline at end of file diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 991f8f0e..fb697ef8 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -107,12 +107,3 @@ def test_module_kind(): module = get_module("mkdocs.plugins") assert module assert module.kind == "module" - - -def test_property(): - module = get_module("mkapi.objects") - assert module - assert module.id == "mkapi.objects" - f = module.get_function("get_object") - assert f - assert f.id == "mkapi.objects.get_object" diff --git a/tests/pages/test_page.py b/tests/pages/test_page.py index a875cd4a..a344efcb 100644 --- a/tests/pages/test_page.py +++ b/tests/pages/test_page.py @@ -21,9 +21,9 @@ def test_page(): "/examples/docs/api/a.o.md", "/examples/docs/api/a.s.md", ] - page = Page(source, abs_src_path, abs_api_paths) - m = page.convert_markdown() - print(m) + # page = Page(source, abs_src_path, abs_api_paths) + # m = page.convert_markdown() + # print(m) # assert m.startswith("# Title\n") # assert "" in m # assert "## [a.o](../api/a.o.md#mkapi.core).[base]" in m @@ -34,12 +34,12 @@ def test_page(): # assert "\n####" in m # assert m.endswith("end") - converter = Markdown() - h = page.convert_html(converter.convert(m)) - assert "

Title

" in h - print("-" * 40) - print(h) - assert 0 + # converter = Markdown() + # h = page.convert_html(converter.convert(m)) + # assert "

Title

" in h + # print("-" * 40) + # print(h) + # assert 0 # assert '
' in h # assert 'MKAPI.CORE' in h # assert 'BASE' in h diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 53bb555d..bb206bc0 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,12 +1,12 @@ -from mkapi.objects import get_module -from mkapi.renderer import renderer +# from mkapi.objects import get_module +# from mkapi.renderer import renderer -def test_render_module(google): - markdown = renderer.render_module(google) - assert "# ![mkapi](examples.styles.example_google" in markdown - assert "## ![mkapi](examples.styles.example_google.ExampleClass" in markdown - assert "## ![mkapi](examples.styles.example_google.example_generator" in markdown +# def test_render_module(google): +# markdown = renderer.render_module(google) +# assert "# ![mkapi](examples.styles.example_google" in markdown +# assert "## ![mkapi](examples.styles.example_google.ExampleClass" in markdown +# assert "## ![mkapi](examples.styles.example_google.example_generator" in markdown # def test_module_empty_filters(): From f4f2f91ecca06681b7aa204a43bbbd1959eebcd3 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 7 Jan 2024 21:10:07 +0900 Subject: [PATCH 064/148] get_object --- examples/docs/1.md | 2 +- examples/docs/index.md | 12 ++ mkdocs.yml | 2 +- pyproject.toml | 2 +- src/mkapi/converter.py | 9 +- src/mkapi/nodes.py | 142 +++++++++++++++- src/mkapi/objects.py | 123 +++++++------- src/mkapi/plugins.py | 8 +- src/mkapi/utils.py | 4 + src_old/mkapi/core/node.py | 251 ---------------------------- tests/conftest.py | 2 - tests/docstrings/test_google.py | 278 ++++++++++++++++---------------- tests/docstrings/test_merge.py | 14 +- tests/docstrings/test_numpy.py | 260 ++++++++++++++--------------- tests/inspect/test_field.py | 0 tests/inspect/test_google.py | 41 ----- tests/inspect/test_inspect.py | 39 ----- tests/nodes/test_node.py | 10 +- tests/objects/test_baseclass.py | 17 ++ tests/objects/test_cache.py | 20 +++ tests/objects/test_fullname.py | 1 - tests/objects/test_import.py | 12 ++ tests/objects/test_object.py | 25 ++- 23 files changed, 584 insertions(+), 690 deletions(-) delete mode 100644 src_old/mkapi/core/node.py create mode 100644 tests/inspect/test_field.py delete mode 100644 tests/inspect/test_google.py delete mode 100644 tests/inspect/test_inspect.py create mode 100644 tests/objects/test_baseclass.py create mode 100644 tests/objects/test_cache.py create mode 100644 tests/objects/test_import.py diff --git a/examples/docs/1.md b/examples/docs/1.md index b63fa068..64909ba6 100644 --- a/examples/docs/1.md +++ b/examples/docs/1.md @@ -4,4 +4,4 @@ ## 1-2 -## 1-3 +## 1-3 {#123} diff --git a/examples/docs/index.md b/examples/docs/index.md index 689ca25b..cefbeac2 100644 --- a/examples/docs/index.md +++ b/examples/docs/index.md @@ -1 +1,13 @@ # Home + +[Object][mkapi.objects] + +[ABC][bdc] + +## ![mkapi](mkapi.objects) + +## ![mkapi](mkapi.objects.Object) + +## ![mkapi](mkapi.objects.Module) + +### [Object](mkapi.objects.Object) diff --git a/mkdocs.yml b/mkdocs.yml index 18883e0f..b54addc7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ repo_url: https://github.com/daizutabi/mkapi/ edit_uri: "" theme: - name: mkdocs + name: material highlightjs: true hljs_languages: - yaml diff --git a/pyproject.toml b/pyproject.toml index 8a5ed988..fff78ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ packages = ["src/mkapi"] python = ["3.12"] [tool.hatch.envs.default] -dependencies = ["pytest-cov", "pymdown-extensions"] +dependencies = ["pytest-cov", "mkdocs-material"] [tool.hatch.envs.default.scripts] test = "pytest {args:tests src/mkapi}" diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py index b92f61e4..d4a6b345 100644 --- a/src/mkapi/converter.py +++ b/src/mkapi/converter.py @@ -8,14 +8,15 @@ def convert_module(name: str, filters: list[str]) -> str: """Convert the [Module] instance to markdown text.""" - # if module := get_module(name): - # return renderer.render_module(module) + if module := get_module(name): + # return renderer.render_module(module) + return f"{module}: {id(module)}" return f"{name} not found" def convert_object(name: str, level: int) -> str: - return "xxxx" + return "# ac" def convert_html(name: str, html: str, filters: list[str]) -> str: - return "xxxx" + return f"xxxx {html}" diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py index 06bdefc0..fae8efbe 100644 --- a/src/mkapi/nodes.py +++ b/src/mkapi/nodes.py @@ -1,7 +1,137 @@ -# @property -# def id(self) -> str: # noqa: D102, A003 -# return self.name +"""Node class represents Markdown and HTML structure.""" +from __future__ import annotations -# @property -# def members(self) -> list[Class | Function]: # noqa: D102 -# return self.classes + self.functions +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from mkapi.objects import ( + Attribute, + Class, + Function, + Module, + Object, + Parameter, + Raise, + Return, + get_object, +) +from mkapi.utils import get_by_name + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@dataclass +class Node: + """Node class.""" + + name: str + object: Module | Class | Function | Attribute | Parameter | Raise | Return # noqa: A003 + kind: str + parent: Node | None + members: list[Node] | None + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"{class_name}({self.name!r}, members={len(self)})" + + def __len__(self) -> int: + return len(self.members or []) + + def __contains__(self, name: str) -> bool: + if self.members: + return any(member.object.name == name for member in self.members) + return False + + def get(self, name: str) -> Node | None: # noqa: D102 + return get_by_name(self.members, name) if self.members else None + + def get_kind(self) -> str: + """Returns kind of self.""" + raise NotImplementedError + + def get_markdown(self) -> str: + """Returns a Markdown source for docstring of self.""" + return f"<{self.name}@{id(self)}>" + + def walk(self) -> Iterator[Node]: + """Yields all members.""" + yield self + if self.members: + for member in self.members: + yield from member.walk() + + +def get_node(name: str) -> Node: + """Return a [Node] instance from the object name.""" + obj = get_object(name) + if not obj or not isinstance(obj, Module | Class | Function): + raise NotImplementedError + return _get_node(obj) + + +def _get_node(obj: Module | Class | Function, parent: Node | None = None) -> Node: + """Return a [Node] instance of [Module], [Class], and [Function].""" + node = Node(obj.name, obj, _get_kind(obj), parent, []) + if isinstance(obj, Module): + node.members = list(_iter_members_module(node, obj)) + elif isinstance(obj, Class): + node.members = list(_iter_members_class(node, obj)) + elif isinstance(obj, Function): + node.members = list(_iter_members_function(node, obj)) + else: + raise NotImplementedError + return node + + +def _iter_members_module(node: Node, obj: Module | Class) -> Iterator[Node]: + yield from _iter_attributes(node, obj) + yield from _iter_classes(node, obj) + yield from _iter_functions(node, obj) + + +def _iter_members_class(node: Node, obj: Class) -> Iterator[Node]: + yield from _iter_members_module(node, obj) + yield from _iter_parameters(node, obj) + yield from _iter_raises(node, obj) + # bases + + +def _iter_members_function(node: Node, obj: Function) -> Iterator[Node]: + yield from _iter_parameters(node, obj) + yield from _iter_raises(node, obj) + yield from _iter_returns(node, obj) + + +def _iter_attributes(node: Node, obj: Module | Class) -> Iterator[Node]: + for attr in obj.attributes: + yield Node(attr.name, attr, _get_kind(attr), node, None) + + +def _iter_parameters(node: Node, obj: Class | Function) -> Iterator[Node]: + for arg in obj.parameters: + yield Node(arg.name, arg, _get_kind(arg), node, None) + + +def _iter_raises(node: Node, obj: Class | Function) -> Iterator[Node]: + for raises in obj.raises: + yield Node(raises.name, raises, _get_kind(raises), node, None) + + +def _iter_returns(node: Node, obj: Function) -> Iterator[Node]: + rt = obj.returns + yield Node(rt.name, rt, _get_kind(rt), node, None) + + +def _iter_classes(node: Node, obj: Module | Class) -> Iterator[Node]: + for cls in obj.classes: + yield _get_node(cls, node) + + +def _iter_functions(node: Node, obj: Module | Class) -> Iterator[Node]: + for func in obj.functions: + yield _get_node(func, node) + + +def _get_kind(obj: Object) -> str: + return obj.__class__.__name__.lower() diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 14f40eac..d3dff613 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING from mkapi import docstrings -from mkapi.utils import del_by_name, get_by_name, is_package, unique_names +from mkapi.utils import del_by_name, get_by_name, unique_names if TYPE_CHECKING: from ast import AST @@ -46,6 +46,9 @@ class Object: # noqa: D101 name: str docstring: str | None + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + def __post_init__(self) -> None: # Set parent module name. self.__dict__["__module_name__"] = CURRENT_MODULE_NAME[0] @@ -81,6 +84,9 @@ class Import(Object): # noqa: D101 fullname: str from_: str | None + def get_fullname(self) -> str: # noqa: D102 + return self.fullname + def iter_import_nodes(node: AST) -> Iterator[Import_]: """Yield import nodes.""" @@ -248,20 +254,15 @@ class Callable(Object): # noqa: D101 decorators: list[ast.expr] type_params: list[ast.type_param] raises: list[Raise] - parent: Class | None + parent: Class | Module | None def get_parameter(self, name: str) -> Parameter | None: # noqa: D102 return get_by_name(self.parameters, name) - def get_fullname(self, sep: str = ".") -> str: # noqa: D102 + def get_fullname(self) -> str: # noqa: D102 if self.parent: return f"{self.parent.get_fullname()}.{self.name}" - module_name = self.get_module_name() or "" - return f"{module_name}{sep}{self.name}" - - @property - def id(self) -> str: # noqa: D102, A003 - return self.get_fullname() + return f"...{self.name}" @dataclass(repr=False) @@ -269,8 +270,15 @@ class Function(Callable): # noqa: D101 _node: FunctionDef_ returns: Return + def get(self, name: str) -> Parameter | None: # noqa: D102 + return self.get_parameter(name) + -type ClassMember = Parameter | Attribute | Class | Function +def _set_parent(obj: Class | Module) -> None: + for cls in obj.classes: + cls.parent = obj + for func in obj.functions: + func.parent = obj @dataclass(repr=False) @@ -281,6 +289,11 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] + def __post_init__(self) -> None: + super().__post_init__() + _set_parent(self) + _move_property(self) + def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 return get_by_name(self.attributes, name) @@ -290,7 +303,7 @@ def get_class(self, name: str) -> Class | None: # noqa: D102 def get_function(self, name: str) -> Function | None: # noqa: D102 return get_by_name(self.functions, name) - def get(self, name: str) -> ClassMember | None: # noqa: D102 + def get(self, name: str) -> Parameter | Attribute | Class | Function | None: # noqa: D102 if obj := self.get_parameter(name): return obj if obj := self.get_attribute(name): @@ -329,13 +342,6 @@ def _get_callable_args( return name, docstring, parameters, decorators, type_params, raises -def _set_parent(obj: Class) -> None: - for cls in obj.classes: - cls.parent = obj - for func in obj.functions: - func.parent = obj - - def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" for def_node in iter_callable_nodes(node): @@ -344,10 +350,7 @@ def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: attrs = list(iter_attributes(def_node)) classes, functions = get_callables(def_node) bases: list[Class] = [] - cls = Class(def_node, *args, None, bases, attrs, classes, functions) - _set_parent(cls) - _move_property(cls) - yield cls + yield Class(def_node, *args, None, bases, attrs, classes, functions) else: yield Function(def_node, *args, None, get_return(def_node)) @@ -364,9 +367,6 @@ def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Functi return classes, functions -type ModuleMember = Import | Attribute | Class | Function - - @dataclass(repr=False) class Module(Object): # noqa: D101 docstring: str | Docstring | None @@ -377,6 +377,13 @@ class Module(Object): # noqa: D101 source: str kind: str + def __post_init__(self) -> None: + super().__post_init__() + _set_parent(self) + + def get_fullname(self) -> str: # noqa: D102 + return self.name + def get_import(self, name: str) -> Import | None: # noqa: D102 return get_by_name(self.imports, name) @@ -389,7 +396,7 @@ def get_class(self, name: str) -> Class | None: # noqa: D102 def get_function(self, name: str) -> Function | None: # noqa: D102 return get_by_name(self.functions, name) - def get(self, name: str) -> ModuleMember | None: # noqa: D102 + def get(self, name: str) -> Import | Attribute | Class | Function | None: # noqa: D102 if obj := self.get_import(name): return obj if obj := self.get_attribute(name): @@ -405,7 +412,7 @@ def get_source(self) -> str: return _get_module_source(self.name) if self.name else "" -CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module | None, str]] = {} +CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module, str]] = {} CACHE_MODULE: dict[str, Module | None] = {} @@ -449,41 +456,48 @@ def _get_module_from_node(node: ast.Module) -> Module: def get_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" - if name in CACHE_MODULE: # TODO: reload + if not (node := _get_module_node(name)): + CACHE_MODULE[name] = None + return None + # `_get_module_node` deletes `name` key in CACHE_MODULE if source was old. + if name in CACHE_MODULE: return CACHE_MODULE[name] - if node := _get_module_node(name): - CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. - module = _get_module_from_node(node) - CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. - module.name = name - module.source = _get_module_source(name) # Set from a global cache. - module.kind = "package" if is_package(name) else "module" - _postprocess(module) - CACHE_MODULE[name] = module - return module - CACHE_MODULE[name] = None - return None + CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. + module = _get_module_from_node(node) + CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. + module.name = name + module.source = _get_module_source(name) # Set from a global cache. + _postprocess(module) + CACHE_MODULE[name] = module + return module + +CACHE_OBJECT: dict[str, Module | Class | Function] = {} -def get_object_from_module(name: str, module: Module) -> Module | ModuleMember | None: - """Return a [Object] instance by the name from a [Module] instance.""" - obj = module.get(name) - if isinstance(obj, Import): - return get_object(obj.fullname) - return obj +def _register_object(obj: Module | Class | Function) -> None: + CACHE_OBJECT[obj.get_fullname()] = obj -def get_object(fullname: str) -> Module | ModuleMember | None: + +def get_object(fullname: str) -> Module | Class | Function | None: """Return a [Object] instance by the fullname.""" if module := get_module(fullname): return module + if fullname in CACHE_OBJECT: + return CACHE_OBJECT[fullname] if "." not in fullname: return None - module_name, name = fullname.rsplit(".", maxsplit=1) - if not (module := get_module(module_name)): - return None - return get_object_from_module(name, module) + n = len(fullname.split(".")) + for maxsplit in range(1, n): + module_name, *_ = fullname.rsplit(".", maxsplit) + if module := get_module(module_name) and fullname in CACHE_OBJECT: + return CACHE_OBJECT[fullname] + return None + +# --------------------------------------------------------------------------------- +# Docsting -> Object +# --------------------------------------------------------------------------------- # a1.b_2(c[d]) -> a1, b_2, c, d SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") @@ -601,19 +615,16 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.docstring = docstring -DEBUG_FOR_PYTEST = False # for pytest. - - def _postprocess(obj: Module | Class) -> None: - if DEBUG_FOR_PYTEST: - return for function in obj.functions: + _register_object(function) _merge_docstring(function) if isinstance(obj, Class): del function.parameters[0] # Delete 'self' TODO: static method. for cls in obj.classes: _postprocess(cls) _postprocess_class(cls) + _register_object(obj) _merge_docstring(obj) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 822f1dfe..1f204a31 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -263,8 +263,7 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: module_path = name + ".md" abs_module_path = abs_api_path / module_path abs_api_paths.append(abs_module_path) - with abs_module_path.open("w") as f: - f.write(converter.convert_module(name, filters)) + _create_page(name, abs_module_path, filters) nav_path = (Path(api_path) / module_path).as_posix() title = page_title(name, depth, ispackage) if page_title else name return {title: nav_path} @@ -274,6 +273,11 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: return nav, abs_api_paths +def _create_page(name: str, path: Path, filters: list[str] | None = None) -> None: + with path.open("w") as f: + f.write(converter.convert_module(name, filters or [])) + + def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: if func := _get_function(plugin, "on_config"): msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 733b542b..520b0815 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -162,6 +162,10 @@ def get_by_name[T](items: list[T], name: str, attr: str = "name") -> T | None: return None +def get_by_kind[T](items: list[T], kind: str) -> T | None: # noqa: D103 + return get_by_name(items, kind, attr="kind") + + def del_by_name[T](items: list[T], name: str, attr: str = "name") -> None: # noqa: D103 for k, item in enumerate(items): if getattr(item, attr, None) == name: diff --git a/src_old/mkapi/core/node.py b/src_old/mkapi/core/node.py deleted file mode 100644 index 44ae9d6c..00000000 --- a/src_old/mkapi/core/node.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Node class that has tree structure.""" -import inspect -from collections.abc import Callable, Iterator -from dataclasses import dataclass, field -from pathlib import Path -from types import FunctionType -from typing import Optional - -from mkapi.core import preprocess -from mkapi.core.base import Base, Type -from mkapi.core.object import from_object, get_object, get_origin, get_sourcefiles -from mkapi.core.structure import Object, Tree -from mkapi.inspect.attribute import isdataclass - - -@dataclass(repr=False) -class Node(Tree): - """Node class represents an object. - - Args: - sourcefile_index: If `obj` is a member of class, this value is the index of - unique source files given by `mro()` of the class. Otherwise, 0. - - Attributes: - parent: Parent Node instance. - members: Member Node instances. - """ - - parent: Optional["Node"] = field(default=None, init=False) - members: list["Node"] = field(init=False) - sourcefile_index: int = 0 - - def __post_init__(self) -> None: - super().__post_init__() - - if self.object.kind in ["class", "dataclass"] and not self.docstring: - for member in self.members: - if member.object.name == "__init__" and member.docstring: - markdown = member.docstring.sections[0].markdown - if not markdown.startswith("Initialize self"): - self.docstring = member.docstring - self.members = [m for m in self.members if m.object.name != "__init__"] - - doc = self.docstring - if doc and "property" in self.object.kind: # noqa: SIM102 - if not doc.type and len(doc.sections) == 1 and doc.sections[0].name == "": - section = doc.sections[0] - markdown = section.markdown - type_, markdown = preprocess.split_type(markdown) - if type_: - doc.type = Type(type_) - section.markdown = markdown - - if doc and doc.type: - self.object.type = doc.type - doc.type = Type() - - def __iter__(self) -> Iterator[Base]: - yield from self.object - yield from self.docstring - for member in self.members: - yield from member - - def get_kind(self) -> str: - """Return node kind.""" - if inspect.ismodule(self.obj): - if self.sourcefile.endswith("__init__.py"): - return "package" - return "module" - if isinstance(self.obj, property): - kind = "readwrite property" if self.obj.fset else "readonly property" - else: - kind = get_kind(get_origin(self.obj)) - if is_abstract(self.obj): - return "abstract " + kind - return kind - - def get_members(self) -> list["Node"]: - """Return members.""" - return get_members(self.obj) - - def get_markdown( - self, - level: int = 0, - callback: Callable[[Base], str] | None = None, - ) -> str: - """Return a Markdown source for docstring of this object. - - Args: - level: Heading level. If 0, `
` tags are used. - callback: To modify Markdown source. - """ - markdowns = [] - member_objects = [member.object for member in self.members] - class_name = "" - for base in self: - markdown = callback(base) if callback else base.markdown - markdown = markdown.replace("{class}", class_name) - if isinstance(base, Object): - if level: - if base == self.object: - markdown = "#" * level + " " + markdown - elif base in member_objects: - markdown = "#" * (level + 1) + " " + markdown - if "class" in base.kind: - class_name = base.name - markdowns.append(markdown) - return "\n\n\n\n".join(markdowns) - - def set_html(self, html: str) -> None: - """Set HTML to [Base]() instances recursively. - - Args: - html: HTML that is provided by a Markdown converter. - """ - for base, html_ in zip(self, html.split(""), strict=False): - base.set_html(html_.strip()) - - def get_html(self, filters: list[str] | None = None) -> str: - """Render and return HTML.""" - from mkapi.core.renderer import renderer - - return renderer.render(self, filters) - - -def is_abstract(obj: object) -> bool: - """Return true if `obj` is abstract.""" - if inspect.isabstract(obj): - return True - if hasattr(obj, "__isabstractmethod__") and obj.__isabstractmethod__: # type: ignore - return True - return False - - -def has_self(obj: object) -> bool: - """Return true if `obj` has `__self__`.""" - try: - return hasattr(obj, "__self__") - except KeyError: - return False - - -def get_kind_self(obj: object) -> str: # noqa: D103 - try: - self = obj.__self__ # type: ignore - except KeyError: - return "" - if isinstance(self, type) or type(type(self)): # Issue#18 - return "classmethod" - return "" - - -def get_kind_function(obj: FunctionType) -> str: # noqa: D103 - try: - parameters = inspect.signature(obj).parameters - except (ValueError, TypeError): - return "" - if parameters and next(iter(parameters)) == "self": - return "method" - if hasattr(obj, "__qualname__") and "." in obj.__qualname__: - return "staticmethod" - return "function" - - -KIND_FUNCTIONS: list[tuple[Callable[..., bool], str | Callable[..., str]]] = [ - (isdataclass, "dataclass"), - (inspect.isclass, "class"), - (inspect.isgeneratorfunction, "generator"), - (has_self, get_kind_self), - (inspect.isfunction, get_kind_function), -] - - -def get_kind(obj: object) -> str: - """Return kind of object.""" - for func, kind in KIND_FUNCTIONS: - if func(obj) and (kind_ := kind if isinstance(kind, str) else kind(obj)): - return kind_ - return "" - - -def is_member(obj: object, name: str = "", sourcefiles: list[str] | None = None) -> int: # noqa: PLR0911 - """Return an integer thats indicates if `obj` is a member or not. - - * $-1$ : Is not a member. - * $>0$ : Is a member. If the value is larger than 0, `obj` is defined - in different file and the value is corresponding to the index of unique - source files of superclasses. - - Args: - name: Object name. - obj: Object - sourcefiles: Parent source files. If the parent is a class, - those of the superclasses should be included in the order - of `mro()`. - """ - name = name or obj.__name__ - obj = get_origin(obj) - if name in ["__func__", "__self__", "__base__", "__bases__"]: - return -1 - if name.startswith("_"): # noqa: SIM102 - if not name.startswith("__") or not name.endswith("__"): - return -1 - if not get_kind(obj): - return -1 - try: - sourcefile = inspect.getsourcefile(obj) # type: ignore - except TypeError: - return -1 - if not sourcefile: - return -1 - if not sourcefiles: - return 0 - sourcefile_path = Path(sourcefile) - for sourcefile_index, parent_sourcefile in enumerate(sourcefiles): - if Path(parent_sourcefile) == sourcefile_path: - return sourcefile_index - return -1 - - -def get_members(obj: object) -> list[Node]: - """Return members.""" - sourcefiles = get_sourcefiles(obj) - members = [] - for name, obj_ in inspect.getmembers(obj): - sourcefile_index = is_member(obj_, name, sourcefiles) - if sourcefile_index != -1 and not from_object(obj_): - member = get_node(obj_, sourcefile_index) - if member.docstring: - members.append(member) - return sorted(members, key=lambda x: (-x.sourcefile_index, x.lineno)) - - -def get_node(name: str | object, sourcefile_index: int = 0) -> Node: - """Return a Node instace by name or object. - - Args: - name: Object name or object itself. - sourcefile_index: If `obj` is a member of class, this value is the index of - unique source files given by `mro()` of the class. Otherwise, 0. - """ - obj = get_object(name) if isinstance(name, str) else name - return Node(obj, sourcefile_index) - - -def get_node_from_module(name: str | object) -> None: - """Return a Node instace by name or object from `modules` dict.""" - from mkapi.core.module import modules - - obj = get_object(name) if isinstance(name, str) else name - return modules[obj.__module__].node[obj.__qualname__] # type: ignore diff --git a/tests/conftest.py b/tests/conftest.py index c5dfd0c6..55dd0fe9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,13 +3,11 @@ import pytest -import mkapi.objects from mkapi.objects import get_module @pytest.fixture(scope="module") def google(): - mkapi.objects.DEBUG_FOR_PYTEST = True path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 5810509e..51f78296 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -1,139 +1,139 @@ -from mkapi.docstrings import ( - _iter_items, - _iter_sections, - iter_items, - parse, - split_item, - split_section, - split_without_name, -) -from mkapi.objects import Module - - -def test_split_section(): - f = split_section - for style in ["google", "numpy"]: - assert f("A", style) == ("", "A") # type: ignore - assert f("A:\n a\n b", "google") == ("A", "a\nb") - assert f("A\n a\n b", "google") == ("", "A\n a\n b") - assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") - assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") - assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") - - -def test_iter_sections_short(): - sections = list(_iter_sections("", "google")) - assert sections == [] - sections = list(_iter_sections("x", "google")) - assert sections == [("", "x")] - sections = list(_iter_sections("x\n", "google")) - assert sections == [("", "x")] - sections = list(_iter_sections("x\n\n", "google")) - assert sections == [("", "x")] - - -def test_iter_sections(google: Module): - doc = google.docstring - assert isinstance(doc, str) - sections = list(_iter_sections(doc, "google")) - assert len(sections) == 6 - assert sections[0][1].startswith("Example Google") - assert sections[0][1].endswith("indented text.") - assert sections[1][0] == "Examples" - assert sections[1][1].startswith("Examples can be") - assert sections[1][1].endswith("google.py") - assert sections[2][1].startswith("Section breaks") - assert sections[2][1].endswith("section starts.") - assert sections[3][0] == "Attributes" - assert sections[3][1].startswith("module_level_") - assert sections[3][1].endswith("with it.") - assert sections[4][0] == "Todo" - assert sections[4][1].startswith("* For") - assert sections[4][1].endswith("extension") - assert sections[5][1].startswith("..") - assert sections[5][1].endswith(".html") - - -def test_iter_items(google: Module): - doc = google.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[1][1] - items = list(_iter_items(section)) - assert len(items) == 4 - assert items[0].startswith("param1") - assert items[1].startswith("param2") - assert items[2].startswith("*args") - assert items[3].startswith("**kwargs") - doc = google.docstring - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[3][1] - items = list(_iter_items(section)) - assert len(items) == 1 - assert items[0].startswith("module_") - - -def test_split_item(google: Module): - doc = google.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - sections = list(_iter_sections(doc, "google")) - section = sections[1][1] - items = list(_iter_items(section)) - x = split_item(items[0], "google") - assert x == ("param1", "int", "The first parameter.") - x = split_item(items[1], "google") - assert x[:2] == ("param2", ":obj:`str`, optional") - assert x[2].endswith("should be indented.") - x = split_item(items[2], "google") - assert x == ("*args", "", "Variable length argument list.") - section = sections[3][1] - items = list(_iter_items(section)) - x = split_item(items[0], "google") - assert x[:2] == ("AttributeError", "") - assert x[2].endswith("the interface.") - - -def test_iter_items_class(google: Module): - doc = google.get("ExampleClass").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[1][1] - x = list(iter_items(section, "google")) - assert x[0].name == "attr1" - assert x[0].type == "str" - assert x[0].description == "Description of `attr1`." - assert x[1].name == "attr2" - assert x[1].type == ":obj:`int`, optional" - assert x[1].description == "Description of `attr2`." - doc = google.get("ExampleClass").get("__init__").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[2][1] - x = list(iter_items(section, "google")) - assert x[0].name == "param1" - assert x[0].type == "str" - assert x[0].description == "Description of `param1`." - assert x[1].description == "Description of `param2`. Multiple\nlines are supported." - - -def test_split_without_name(google: Module): - doc = google.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[2][1] - x = split_without_name(section, "google") - assert x[0] == "bool" - assert x[1].startswith("True if") - assert x[1].endswith(" }") - - -def test_repr(google: Module): - r = repr(parse(google.docstring, "google")) # type: ignore - assert r == "Docstring(num_sections=6)" - - -def test_iter_items_raises(google: Module): - doc = google.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - name, section = list(_iter_sections(doc, "google"))[3] - assert name == "Raises" - items = list(iter_items(section, "google", name)) - assert len(items) == 2 - assert items[0].type == items[0].name == "AttributeError" - assert items[1].type == items[1].name == "ValueError" +# from mkapi.docstrings import ( +# _iter_items, +# _iter_sections, +# iter_items, +# parse, +# split_item, +# split_section, +# split_without_name, +# ) +# from mkapi.objects import Module + + +# def test_split_section(): +# f = split_section +# for style in ["google", "numpy"]: +# assert f("A", style) == ("", "A") # type: ignore +# assert f("A:\n a\n b", "google") == ("A", "a\nb") +# assert f("A\n a\n b", "google") == ("", "A\n a\n b") +# assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") +# assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") +# assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +# def test_iter_sections_short(): +# sections = list(_iter_sections("", "google")) +# assert sections == [] +# sections = list(_iter_sections("x", "google")) +# assert sections == [("", "x")] +# sections = list(_iter_sections("x\n", "google")) +# assert sections == [("", "x")] +# sections = list(_iter_sections("x\n\n", "google")) +# assert sections == [("", "x")] + + +# def test_iter_sections(google: Module): +# doc = google.docstring +# assert isinstance(doc, str) +# sections = list(_iter_sections(doc, "google")) +# assert len(sections) == 6 +# assert sections[0][1].startswith("Example Google") +# assert sections[0][1].endswith("indented text.") +# assert sections[1][0] == "Examples" +# assert sections[1][1].startswith("Examples can be") +# assert sections[1][1].endswith("google.py") +# assert sections[2][1].startswith("Section breaks") +# assert sections[2][1].endswith("section starts.") +# assert sections[3][0] == "Attributes" +# assert sections[3][1].startswith("module_level_") +# assert sections[3][1].endswith("with it.") +# assert sections[4][0] == "Todo" +# assert sections[4][1].startswith("* For") +# assert sections[4][1].endswith("extension") +# assert sections[5][1].startswith("..") +# assert sections[5][1].endswith(".html") + + +# def test_iter_items(google: Module): +# doc = google.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "google"))[1][1] +# items = list(_iter_items(section)) +# assert len(items) == 4 +# assert items[0].startswith("param1") +# assert items[1].startswith("param2") +# assert items[2].startswith("*args") +# assert items[3].startswith("**kwargs") +# doc = google.docstring +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "google"))[3][1] +# items = list(_iter_items(section)) +# assert len(items) == 1 +# assert items[0].startswith("module_") + + +# def test_split_item(google: Module): +# doc = google.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# sections = list(_iter_sections(doc, "google")) +# section = sections[1][1] +# items = list(_iter_items(section)) +# x = split_item(items[0], "google") +# assert x == ("param1", "int", "The first parameter.") +# x = split_item(items[1], "google") +# assert x[:2] == ("param2", ":obj:`str`, optional") +# assert x[2].endswith("should be indented.") +# x = split_item(items[2], "google") +# assert x == ("*args", "", "Variable length argument list.") +# section = sections[3][1] +# items = list(_iter_items(section)) +# x = split_item(items[0], "google") +# assert x[:2] == ("AttributeError", "") +# assert x[2].endswith("the interface.") + + +# def test_iter_items_class(google: Module): +# doc = google.get("ExampleClass").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "google"))[1][1] +# x = list(iter_items(section, "google")) +# assert x[0].name == "attr1" +# assert x[0].type == "str" +# assert x[0].description == "Description of `attr1`." +# assert x[1].name == "attr2" +# assert x[1].type == ":obj:`int`, optional" +# assert x[1].description == "Description of `attr2`." +# doc = google.get("ExampleClass").get("__init__").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "google"))[2][1] +# x = list(iter_items(section, "google")) +# assert x[0].name == "param1" +# assert x[0].type == "str" +# assert x[0].description == "Description of `param1`." +# assert x[1].description == "Description of `param2`. Multiple\nlines are supported." + + +# def test_split_without_name(google: Module): +# doc = google.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "google"))[2][1] +# x = split_without_name(section, "google") +# assert x[0] == "bool" +# assert x[1].startswith("True if") +# assert x[1].endswith(" }") + + +# def test_repr(google: Module): +# r = repr(parse(google.docstring, "google")) # type: ignore +# assert r == "Docstring(num_sections=6)" + + +# def test_iter_items_raises(google: Module): +# doc = google.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# name, section = list(_iter_sections(doc, "google"))[3] +# assert name == "Raises" +# items = list(iter_items(section, "google", name)) +# assert len(items) == 2 +# assert items[0].type == items[0].name == "AttributeError" +# assert items[1].type == items[1].name == "ValueError" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index b7449c9d..3662ded9 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -14,10 +14,10 @@ def test_iter_merged_items(): assert c[2].type == "list" -def test_merge(google): - a = parse(google.get("ExampleClass").docstring, "google") - b = parse(google.get("ExampleClass").get("__init__").docstring, "google") - doc = merge(a, b) - assert doc - assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] - doc.sections[-1].description.endswith("with it.") +# def test_merge(google): +# a = parse(google.get("ExampleClass").docstring, "google") +# b = parse(google.get("ExampleClass").get("__init__").docstring, "google") +# doc = merge(a, b) +# assert doc +# assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] +# doc.sections[-1].description.endswith("with it.") diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index ad30772c..882305c4 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -1,130 +1,130 @@ -from mkapi.docstrings import ( - _iter_items, - _iter_sections, - iter_items, - split_item, - split_section, - split_without_name, -) -from mkapi.objects import Module - - -def test_split_section(): - f = split_section - assert f("A", "numpy") == ("", "A") # type: ignore - assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") - assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") - assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") - - -def test_iter_sections_short(): - sections = list(_iter_sections("", "numpy")) - assert sections == [] - sections = list(_iter_sections("x", "numpy")) - assert sections == [("", "x")] - sections = list(_iter_sections("x\n", "numpy")) - assert sections == [("", "x")] - sections = list(_iter_sections("x\n\n", "numpy")) - assert sections == [("", "x")] - - -def test_iter_sections(numpy: Module): - doc = numpy.docstring - assert isinstance(doc, str) - sections = list(_iter_sections(doc, "numpy")) - assert len(sections) == 7 - assert sections[0][1].startswith("Example NumPy") - assert sections[0][1].endswith("equal length.") - assert sections[1][0] == "Examples" - assert sections[1][1].startswith("Examples can be") - assert sections[1][1].endswith("numpy.py") - assert sections[2][1].startswith("Section breaks") - assert sections[2][1].endswith("be\nindented:") - assert sections[3][0] == "Notes" - assert sections[3][1].startswith("This is an") - assert sections[3][1].endswith("surrounding text.") - assert sections[4][1].startswith("If a section") - assert sections[4][1].endswith("unindented text.") - assert sections[5][0] == "Attributes" - assert sections[5][1].startswith("module_level") - assert sections[5][1].endswith("with it.") - assert sections[6][1].startswith("..") - assert sections[6][1].endswith(".rst.txt") - - -def test_iter_items(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[1][1] - items = list(_iter_items(section)) - assert len(items) == 4 - assert items[0].startswith("param1") - assert items[1].startswith("param2") - assert items[2].startswith("*args") - assert items[3].startswith("**kwargs") - doc = numpy.docstring - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[5][1] - items = list(_iter_items(section)) - assert len(items) == 1 - assert items[0].startswith("module_") - - -def test_split_item(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - sections = list(_iter_sections(doc, "numpy")) - items = list(_iter_items(sections[1][1])) - x = split_item(items[0], "numpy") - assert x == ("param1", "int", "The first parameter.") - x = split_item(items[1], "numpy") - assert x[:2] == ("param2", ":obj:`str`, optional") - assert x[2] == "The second parameter." - x = split_item(items[2], "numpy") - assert x == ("*args", "", "Variable length argument list.") - items = list(_iter_items(sections[3][1])) - x = split_item(items[0], "numpy") - assert x[:2] == ("AttributeError", "") - assert x[2].endswith("the interface.") - - -def test_iter_items_class(numpy: Module): - doc = numpy.get("ExampleClass").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[1][1] - x = list(iter_items(section, "numpy")) - assert x[0].name == "attr1" - assert x[0].type == "str" - assert x[0].description == "Description of `attr1`." - assert x[1].name == "attr2" - assert x[1].type == ":obj:`int`, optional" - assert x[1].description == "Description of `attr2`." - doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[2][1] - x = list(iter_items(section, "numpy")) - assert x[0].name == "param1" - assert x[0].type == "str" - assert x[0].description == "Description of `param1`." - assert x[1].description == "Description of `param2`. Multiple\nlines are supported." - - -def test_get_return(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[2][1] - x = split_without_name(section, "numpy") - assert x[0] == "bool" - assert x[1].startswith("True if") - assert x[1].endswith(" }") - - -def test_iter_items_raises(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore - assert isinstance(doc, str) - name, section = list(_iter_sections(doc, "numpy"))[3] - assert name == "Raises" - items = list(iter_items(section, "numpy", name)) - assert len(items) == 2 - assert items[0].type == items[0].name == "AttributeError" - assert items[1].type == items[1].name == "ValueError" +# from mkapi.docstrings import ( +# _iter_items, +# _iter_sections, +# iter_items, +# split_item, +# split_section, +# split_without_name, +# ) +# from mkapi.objects import Module + + +# def test_split_section(): +# f = split_section +# assert f("A", "numpy") == ("", "A") # type: ignore +# assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") +# assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") +# assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +# def test_iter_sections_short(): +# sections = list(_iter_sections("", "numpy")) +# assert sections == [] +# sections = list(_iter_sections("x", "numpy")) +# assert sections == [("", "x")] +# sections = list(_iter_sections("x\n", "numpy")) +# assert sections == [("", "x")] +# sections = list(_iter_sections("x\n\n", "numpy")) +# assert sections == [("", "x")] + + +# def test_iter_sections(numpy: Module): +# doc = numpy.docstring +# assert isinstance(doc, str) +# sections = list(_iter_sections(doc, "numpy")) +# assert len(sections) == 7 +# assert sections[0][1].startswith("Example NumPy") +# assert sections[0][1].endswith("equal length.") +# assert sections[1][0] == "Examples" +# assert sections[1][1].startswith("Examples can be") +# assert sections[1][1].endswith("numpy.py") +# assert sections[2][1].startswith("Section breaks") +# assert sections[2][1].endswith("be\nindented:") +# assert sections[3][0] == "Notes" +# assert sections[3][1].startswith("This is an") +# assert sections[3][1].endswith("surrounding text.") +# assert sections[4][1].startswith("If a section") +# assert sections[4][1].endswith("unindented text.") +# assert sections[5][0] == "Attributes" +# assert sections[5][1].startswith("module_level") +# assert sections[5][1].endswith("with it.") +# assert sections[6][1].startswith("..") +# assert sections[6][1].endswith(".rst.txt") + + +# def test_iter_items(numpy: Module): +# doc = numpy.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "numpy"))[1][1] +# items = list(_iter_items(section)) +# assert len(items) == 4 +# assert items[0].startswith("param1") +# assert items[1].startswith("param2") +# assert items[2].startswith("*args") +# assert items[3].startswith("**kwargs") +# doc = numpy.docstring +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "numpy"))[5][1] +# items = list(_iter_items(section)) +# assert len(items) == 1 +# assert items[0].startswith("module_") + + +# def test_split_item(numpy: Module): +# doc = numpy.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# sections = list(_iter_sections(doc, "numpy")) +# items = list(_iter_items(sections[1][1])) +# x = split_item(items[0], "numpy") +# assert x == ("param1", "int", "The first parameter.") +# x = split_item(items[1], "numpy") +# assert x[:2] == ("param2", ":obj:`str`, optional") +# assert x[2] == "The second parameter." +# x = split_item(items[2], "numpy") +# assert x == ("*args", "", "Variable length argument list.") +# items = list(_iter_items(sections[3][1])) +# x = split_item(items[0], "numpy") +# assert x[:2] == ("AttributeError", "") +# assert x[2].endswith("the interface.") + + +# def test_iter_items_class(numpy: Module): +# doc = numpy.get("ExampleClass").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "numpy"))[1][1] +# x = list(iter_items(section, "numpy")) +# assert x[0].name == "attr1" +# assert x[0].type == "str" +# assert x[0].description == "Description of `attr1`." +# assert x[1].name == "attr2" +# assert x[1].type == ":obj:`int`, optional" +# assert x[1].description == "Description of `attr2`." +# doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "numpy"))[2][1] +# x = list(iter_items(section, "numpy")) +# assert x[0].name == "param1" +# assert x[0].type == "str" +# assert x[0].description == "Description of `param1`." +# assert x[1].description == "Description of `param2`. Multiple\nlines are supported." + + +# def test_get_return(numpy: Module): +# doc = numpy.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# section = list(_iter_sections(doc, "numpy"))[2][1] +# x = split_without_name(section, "numpy") +# assert x[0] == "bool" +# assert x[1].startswith("True if") +# assert x[1].endswith(" }") + + +# def test_iter_items_raises(numpy: Module): +# doc = numpy.get("module_level_function").docstring # type: ignore +# assert isinstance(doc, str) +# name, section = list(_iter_sections(doc, "numpy"))[3] +# assert name == "Raises" +# items = list(iter_items(section, "numpy", name)) +# assert len(items) == 2 +# assert items[0].type == items[0].name == "AttributeError" +# assert items[1].type == items[1].name == "ValueError" diff --git a/tests/inspect/test_field.py b/tests/inspect/test_field.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/inspect/test_google.py b/tests/inspect/test_google.py deleted file mode 100644 index 8a4d902e..00000000 --- a/tests/inspect/test_google.py +++ /dev/null @@ -1,41 +0,0 @@ -import ast - -# from mkapi.docstring import get_docstring -# from mkapi.objects import get_module - - -def test_google(google): - print(google) - print(google.docstring) - print(google.attributes) - # print(google.functions) - # print(google.attributes) - # assert 0 - # print(cls._node.lineno) - # print(cls._node.end_lineno) - # print(cls._node.__dict__) - # print(cls.__module_name__) - # lines = source.split("\n") - # print("v" * 10) - # print("\n".join(lines[cls._node.lineno - 1 : cls._node.end_lineno - 1])) - # print("^" * 10) - # cls = module.get("MkAPIConfig") - # print(cls) - # x = module.get("Config") - # print(x) - # m = get_module_from_import(x) - # print(m) - # print(m.get("Config")) - # print(m.get("Config").unparse()) - - # print(ast.unparse(cls._node)) - # print(cls.attributes[0].default) - # g = module.get("config_options") - # assert g - # print(g) - # m = get_module_from_import(g) - # assert m - # print(m) - # print(cls.bases) - # print(cls._node.bases) - # assert 0 diff --git a/tests/inspect/test_inspect.py b/tests/inspect/test_inspect.py deleted file mode 100644 index c01baf23..00000000 --- a/tests/inspect/test_inspect.py +++ /dev/null @@ -1,39 +0,0 @@ -import ast - -from mkapi.objects import get_module - - -def test_(): - module = get_module("mkapi.plugins") - assert module - cls = module.get("MkAPIPlugin") - print(cls.get_source(10)) - # assert 0 - # print(cls._node.lineno) - # print(cls._node.end_lineno) - # print(cls._node.__dict__) - # print(cls.__module_name__) - # lines = source.split("\n") - # print("v" * 10) - # print("\n".join(lines[cls._node.lineno - 1 : cls._node.end_lineno - 1])) - # print("^" * 10) - # cls = module.get("MkAPIConfig") - # print(cls) - # x = module.get("Config") - # print(x) - # m = get_module_from_import(x) - # print(m) - # print(m.get("Config")) - # print(m.get("Config").unparse()) - - # print(ast.unparse(cls._node)) - # print(cls.attributes[0].default) - # g = module.get("config_options") - # assert g - # print(g) - # m = get_module_from_import(g) - # assert m - # print(m) - # print(cls.bases) - # print(cls._node.bases) - # assert 0 diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py index 3079b8e4..b1246476 100644 --- a/tests/nodes/test_node.py +++ b/tests/nodes/test_node.py @@ -1,3 +1,11 @@ +from mkapi.nodes import get_node + + +def test_node(): + node = get_node("mkdocs.plugins") + for m in node.walk(): + print(m) + # def test_property(): # module = get_module("mkapi.objects") @@ -5,4 +13,4 @@ # assert module.id == "mkapi.objects" # f = module.get_function("get_object") # assert f -# assert f.id == "mkapi.objects.get_object" \ No newline at end of file +# assert f.id == "mkapi.objects.get_object" diff --git a/tests/objects/test_baseclass.py b/tests/objects/test_baseclass.py new file mode 100644 index 00000000..216c5590 --- /dev/null +++ b/tests/objects/test_baseclass.py @@ -0,0 +1,17 @@ +import ast + +from mkapi.objects import CACHE_OBJECT, Class, get_object + + +def test_baseclass(): + a = get_object("mkapi.objects.CACHE_OBJECT") + print(a) + assert 0 + # # cls = get_object("mkapi.plugins.MkAPIConfig") + # # assert isinstance(cls, Class) + # # base = cls._node.bases[0] + # # assert isinstance(base, ast.Name) + # # print(ast.unparse(base)) + + # print(get_object("mkapi.plugins.MkdocsConfig")) + # print(list(CACHE_OBJECT.keys())) diff --git a/tests/objects/test_cache.py b/tests/objects/test_cache.py new file mode 100644 index 00000000..b4b8410b --- /dev/null +++ b/tests/objects/test_cache.py @@ -0,0 +1,20 @@ +from mkapi.objects import ( + CACHE_MODULE, + CACHE_MODULE_NODE, + CACHE_OBJECT, + get_module, + get_object, +) + + +def test_cache(): + CACHE_MODULE.clear() + CACHE_MODULE_NODE.clear() + CACHE_OBJECT.clear() + module = get_module("mkapi.objects") + c = get_object("mkapi.objects.Object") + f = get_object("mkapi.objects.Module.get_class") + assert c + assert f + assert c.get_module() is module + assert f.get_module() is module diff --git a/tests/objects/test_fullname.py b/tests/objects/test_fullname.py index 91027cef..274f8b57 100644 --- a/tests/objects/test_fullname.py +++ b/tests/objects/test_fullname.py @@ -35,4 +35,3 @@ def test_get_fullname(google): assert c.get_fullname() == "examples.styles.example_google.ExampleClass" name = "examples.styles.example_google.ExampleClass.example_method" assert f.get_fullname() == name - assert c.get_fullname("#") == "examples.styles.example_google#ExampleClass" diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py new file mode 100644 index 00000000..37512214 --- /dev/null +++ b/tests/objects/test_import.py @@ -0,0 +1,12 @@ +import ast +from importlib.util import find_spec + +from mkapi.objects import _get_module_from_node, get_module, get_object + + +def test_import(): + module = get_module("mkapi.plugins") + assert module + for x in module.imports: + obj = get_object(x.fullname) + print(x.name, x.fullname, obj) diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index fb697ef8..ec505896 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -5,6 +5,7 @@ import mkapi.objects from mkapi.objects import ( + CACHE_MODULE_NODE, _get_module_node, get_module, get_object, @@ -81,8 +82,6 @@ def test_repr(): assert repr(module) == "Module(mkapi.objects)" obj = get_object("mkapi.objects.Object") assert repr(obj) == "Class(Object)" - obj = get_object("mkapi.plugins.BasePlugin") - assert repr(obj) == "Class(BasePlugin)" def test_get_module_source(): @@ -100,10 +99,20 @@ def test_get_module_source(): assert "MkAPIPlugin" in module.get_source() -def test_module_kind(): - module = get_module("mkdocs") - assert module - assert module.kind == "package" - module = get_module("mkdocs.plugins") +def test_get_module_from_object(): + module = get_module("mkdocs.structure.files") assert module - assert module.kind == "module" + c = module.classes[1] + m = c.get_module() + assert module is m + + +def test_get_module_check_mtime(): + m1 = get_module("mkdocs.structure.files") + m2 = get_module("mkdocs.structure.files") + assert m1 is m2 + CACHE_MODULE_NODE.clear() + m3 = get_module("mkdocs.structure.files") + m4 = get_module("mkdocs.structure.files") + assert m2 is not m3 + assert m3 is m4 From 7c3ba34d0b6eb8457f1b6151d5fac4512ff8cd68 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 8 Jan 2024 09:26:50 +0900 Subject: [PATCH 065/148] objects -> ast --- examples/docs/1.md | 2 +- examples/imports/__init__.py | 0 examples/imports/a.py | 2 + examples/imports/b.py | 1 + examples/imports/c.py | 1 + src/mkapi/ast.py | 134 ++++++++++++++ src/mkapi/docstrings.py | 86 ++++----- src/mkapi/objects.py | 312 +++++++++++--------------------- tests/docstrings/conftest.py | 22 +++ tests/docstrings/test_google.py | 278 ++++++++++++++-------------- tests/docstrings/test_merge.py | 16 +- tests/docstrings/test_numpy.py | 260 +++++++++++++------------- tests/objects/test_baseclass.py | 4 +- tests/objects/test_cache.py | 20 +- tests/objects/test_import.py | 12 +- tests/objects/test_merge.py | 8 +- tests/objects/test_object.py | 62 ++----- 17 files changed, 626 insertions(+), 594 deletions(-) create mode 100644 examples/imports/__init__.py create mode 100644 examples/imports/a.py create mode 100644 examples/imports/b.py create mode 100644 examples/imports/c.py create mode 100644 src/mkapi/ast.py create mode 100644 tests/docstrings/conftest.py diff --git a/examples/docs/1.md b/examples/docs/1.md index 64909ba6..3db15f64 100644 --- a/examples/docs/1.md +++ b/examples/docs/1.md @@ -1,4 +1,4 @@ -# 1 +# 3 ## 1-1 diff --git a/examples/imports/__init__.py b/examples/imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/imports/a.py b/examples/imports/a.py new file mode 100644 index 00000000..7f1f91a8 --- /dev/null +++ b/examples/imports/a.py @@ -0,0 +1,2 @@ +def f(): + pass \ No newline at end of file diff --git a/examples/imports/b.py b/examples/imports/b.py new file mode 100644 index 00000000..2e19619d --- /dev/null +++ b/examples/imports/b.py @@ -0,0 +1 @@ +from a import f \ No newline at end of file diff --git a/examples/imports/c.py b/examples/imports/c.py new file mode 100644 index 00000000..af09a438 --- /dev/null +++ b/examples/imports/c.py @@ -0,0 +1 @@ +from b import f \ No newline at end of file diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py new file mode 100644 index 00000000..d4aaadd8 --- /dev/null +++ b/src/mkapi/ast.py @@ -0,0 +1,134 @@ +"""AST module.""" +from __future__ import annotations + +import ast +import importlib.util +from ast import ( + AnnAssign, + Assign, + AsyncFunctionDef, + ClassDef, + Constant, + Expr, + FunctionDef, + Import, + ImportFrom, + Module, + Name, + TypeAlias, +) +from inspect import Parameter, cleandoc +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ast import AST + from collections.abc import Iterator + from inspect import _ParameterKind + + +def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: + """Yield import nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.Import | ImportFrom): + yield child + elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): + yield from iter_import_nodes(child) + + +def _get_pseudo_docstring(node: AST) -> str | None: + if not isinstance(node, Expr) or not isinstance(node.value, Constant): + return None + doc = node.value.value + return cleandoc(doc) if isinstance(doc, str) else None + + +def iter_assign_nodes( + node: Module | ClassDef, +) -> Iterator[AnnAssign | Assign | TypeAlias]: + """Yield assign nodes.""" + assign_node: AnnAssign | Assign | TypeAlias | None = None + for child in ast.iter_child_nodes(node): + if isinstance(child, AnnAssign | Assign | TypeAlias): + if assign_node: + yield assign_node + child.__doc__ = None + assign_node = child + else: + if assign_node: + assign_node.__doc__ = _get_pseudo_docstring(child) + yield assign_node + assign_node = None + if assign_node: + assign_node.__doc__ = None + yield assign_node + + +def get_assign_name(node: AnnAssign | Assign | TypeAlias) -> str | None: + """Return the name of the assign node.""" + if isinstance(node, AnnAssign) and isinstance(node.target, Name): + return node.target.id + if isinstance(node, Assign) and isinstance(node.targets[0], Name): + return node.targets[0].id + if isinstance(node, TypeAlias) and isinstance(node.name, Name): + return node.name.id + return None + + +def get_assign_type(node: AnnAssign | Assign | TypeAlias) -> ast.expr | None: + """Return a type annotation of the Assign or TypeAlias AST node.""" + if isinstance(node, AnnAssign): + return node.annotation + if isinstance(node, TypeAlias): + return node.value + return None + + +PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { + Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list + Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list + Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None + Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list + Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None +} + + +def _iter_parameters( + node: FunctionDef | AsyncFunctionDef, +) -> Iterator[tuple[ast.arg, _ParameterKind]]: + for kind, attr in PARAMETER_KIND_DICT.items(): + if args := getattr(node.args, attr): + it = args if isinstance(args, list) else [args] + yield from ((arg, kind) for arg in it) + + +def _iter_defaults(node: FunctionDef | AsyncFunctionDef) -> Iterator[ast.expr | None]: + args = node.args + num_positional = len(args.posonlyargs) + len(args.args) + nones = [None] * num_positional + yield from [*nones, *args.defaults][-num_positional:] + yield from args.kw_defaults + + +def iter_parameters( + node: FunctionDef | AsyncFunctionDef, +) -> Iterator[tuple[ast.arg, _ParameterKind, ast.expr | None]]: + """Yield parameters from the function node.""" + it = _iter_defaults(node) + for arg, kind in _iter_parameters(node): + if kind is Parameter.VAR_POSITIONAL: + arg.arg, default = f"*{arg.arg}", None + elif kind is Parameter.VAR_KEYWORD: + arg.arg, default = f"**{arg.arg}", None + else: + default = next(it) + yield arg, kind, default + + +def iter_callable_nodes( + node: Module | ClassDef, +) -> Iterator[FunctionDef | AsyncFunctionDef | ClassDef]: + """Yield callable nodes.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): + yield child diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index c2616dac..a89c8cfd 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -23,14 +23,14 @@ class Item: # noqa: D101 name: str type: str # noqa: A003 - description: str + text: str def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name}:{self.type})" SPLIT_ITEM_PATTERN = re.compile(r"\n\S") -SPLIT_NAME_TYPE_DESC_PATTERN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") +SPLIT_NAME_TYPE_TEXT_PATTERN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") def _iter_items(section: str) -> Iterator[str]: @@ -43,14 +43,14 @@ def _iter_items(section: str) -> Iterator[str]: def _split_item_google(lines: list[str]) -> tuple[str, str, str]: - if m := re.match(SPLIT_NAME_TYPE_DESC_PATTERN, lines[0]): - name, type_, desc = m.groups() + if m := re.match(SPLIT_NAME_TYPE_TEXT_PATTERN, lines[0]): + name, type_, text = m.groups() elif ":" in lines[0]: - name, desc = lines[0].split(":", maxsplit=1) + name, text = lines[0].split(":", maxsplit=1) type_ = "" else: - name, type_, desc = lines[0], "", "" - return name, type_, "\n".join([desc.strip(), *lines[1:]]) + name, type_, text = lines[0], "", "" + return name, type_, "\n".join([text.strip(), *lines[1:]]) def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: @@ -62,7 +62,7 @@ def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: def split_item(item: str, style: Style) -> tuple[str, str, str]: - """Return a tuple of (name, type, description).""" + """Return a tuple of (name, type, text).""" lines = [line.strip() for line in item.split("\n")] if style == "google": return _split_item_google(lines) @@ -74,16 +74,16 @@ def iter_items( style: Style, section_name: str = "Parameters", ) -> Iterator[Item]: - """Yiled a tuple of (name, type, description) of item. + """Yiled a tuple of (name, type, text) of item. If name is 'Raises', the type of [Item] is set by its name. """ for item in _iter_items(section): - name, type_, desc = split_item(item, style) + name, type_, text = split_item(item, style) if section_name != "Raises": - yield Item(name, type_, desc) + yield Item(name, type_, text) else: - yield Item(name, name, desc) + yield Item(name, name, text) @dataclass @@ -148,7 +148,7 @@ def _rename_section(section_name: str) -> str: def split_section(section: str, style: Style) -> tuple[str, str]: - """Return section name and its description.""" + """Return section name and its text.""" lines = section.split("\n") if len(lines) < 2: # noqa: PLR2004 return "", section @@ -160,51 +160,51 @@ def split_section(section: str, style: Style) -> tuple[str, str]: def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: - """Yield (section name, description) pairs by splitting the whole docstring.""" - prev_name, prev_desc = "", "" + """Yield (section name, text) pairs by splitting the whole docstring.""" + prev_name, prev_text = "", "" for section in _split_sections(doc, style): if not section: continue - name, desc = split_section(section, style) - if not desc: + name, text = split_section(section, style) + if not text: continue name = _rename_section(name) if prev_name == name == "": # continuous 'plain' section. - prev_desc = f"{prev_desc}\n\n{desc}" if prev_desc else desc + prev_text = f"{prev_text}\n\n{text}" if prev_text else text continue - elif prev_name == "" and name != "" and prev_desc: - yield prev_name, prev_desc - yield name, desc - prev_name, prev_desc = name, "" - if prev_desc: - yield "", prev_desc + elif prev_name == "" and name != "" and prev_text: + yield prev_name, prev_text + yield name, text + prev_name, prev_text = name, "" + if prev_text: + yield "", prev_text -def split_without_name(desc: str, style: Style) -> tuple[str, str]: - """Return a tuple of (type, description) for Returns or Yields section.""" - lines = desc.split("\n") +def split_without_name(text: str, style: Style) -> tuple[str, str]: + """Return a tuple of (type, text) for Returns or Yields section.""" + lines = text.split("\n") if style == "google" and ":" in lines[0]: - type_, desc_ = lines[0].split(":", maxsplit=1) - return type_.strip(), "\n".join([desc_.strip(), *lines[1:]]) + type_, text_ = lines[0].split(":", maxsplit=1) + return type_.strip(), "\n".join([text_.strip(), *lines[1:]]) if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): return lines[0], join_without_first_indent(lines[1:]) - return "", desc + return "", text def iter_sections(doc: str, style: Style) -> Iterator[Section]: """Yield [Section] instance by splitting the whole docstring.""" - for name, desc in _iter_sections(doc, style): - type_ = desc_ = "" + for name, text in _iter_sections(doc, style): + type_ = text_ = "" items: list[Item] = [] if name in ["Parameters", "Attributes", "Raises"]: - items = list(iter_items(desc, style, name)) + items = list(iter_items(text, style, name)) elif name in ["Returns", "Yields"]: - type_, desc_ = split_without_name(desc, style) + type_, text_ = split_without_name(text, style) elif name in ["Note", "Notes", "Warning", "Warnings"]: - desc_ = add_admonition(name, desc) + text_ = add_admonition(name, text) else: - desc_ = desc - yield Section(name, type_, desc_, items) + text_ = text + yield Section(name, type_, text_, items) @dataclass(repr=False) @@ -239,8 +239,8 @@ def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: elif ai and bi: name_ = ai.name or bi.name type_ = ai.type or bi.type - desc = ai.description or bi.description - yield Item(name_, type_, desc) + text = ai.text or bi.text + yield Item(name_, type_, text) def merge_sections(a: Section, b: Section) -> Section: @@ -248,8 +248,8 @@ def merge_sections(a: Section, b: Section) -> Section: if a.name != b.name: raise ValueError type_ = a.type if a.type else b.type - desc = f"{a.description}\n\n{b.description}".strip() - return Section(a.name, type_, desc, list(iter_merged_items(a.items, b.items))) + text = f"{a.text}\n\n{b.text}".strip() + return Section(a.name, type_, text, list(iter_merged_items(a.items, b.items))) def iter_merge_sections(a: list[Section], b: list[Section]) -> Iterator[Section]: @@ -286,5 +286,5 @@ def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: sections.extend(s for s in b.sections if not s.name) name_ = a.name or b.name type_ = a.type or b.type - desc = a.description or b.description - return Docstring(name_, type_, desc, sections) + text = a.text or b.text + return Docstring(name_, type_, text, sections) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index d3dff613..2644d677 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -2,47 +2,29 @@ from __future__ import annotations import ast +import importlib.util import re -from ast import ( - AnnAssign, - Assign, - AsyncFunctionDef, - ClassDef, - Constant, - Expr, - FunctionDef, - ImportFrom, - Name, - TypeAlias, -) from dataclasses import dataclass, field -from importlib.util import find_spec -from inspect import Parameter as P # noqa: N817 -from inspect import cleandoc from pathlib import Path from typing import TYPE_CHECKING +import mkapi.ast from mkapi import docstrings from mkapi.utils import del_by_name, get_by_name, unique_names if TYPE_CHECKING: - from ast import AST from collections.abc import Iterator from inspect import _ParameterKind from mkapi.docstrings import Docstring, Item, Section, Style -type Import_ = ast.Import | ImportFrom -type FunctionDef_ = AsyncFunctionDef | FunctionDef -type Def = FunctionDef_ | ClassDef -type Assign_ = Assign | AnnAssign | TypeAlias CURRENT_MODULE_NAME: list[str | None] = [None] @dataclass class Object: # noqa: D101 - _node: AST + _node: ast.AST name: str docstring: str | None @@ -67,10 +49,10 @@ def get_module(self) -> Module | None: def get_source(self, maxline: int | None = None) -> str | None: """Return the source code segment.""" - if (name := self.get_module_name()) and (source := _get_module_source(name)): - start, stop = self._node.lineno - 1, self._node.end_lineno - return "\n".join(source.split("\n")[start:stop][:maxline]) - return None + if not (module := self.get_module()) or not (source := module.source): + return None + start, stop = self._node.lineno - 1, self._node.end_lineno + return "\n".join(source.split("\n")[start:stop][:maxline]) def unparse(self) -> str: """Unparse the AST node and return a string expression.""" @@ -79,96 +61,42 @@ def unparse(self) -> str: @dataclass class Import(Object): # noqa: D101 - _node: ast.Import | ImportFrom = field(repr=False) + _node: ast.Import | ast.ImportFrom = field(repr=False) docstring: str | None = field(repr=False) fullname: str from_: str | None + object: Module | Class | Function | Attribute | None # noqa: A003 def get_fullname(self) -> str: # noqa: D102 return self.fullname -def iter_import_nodes(node: AST) -> Iterator[Import_]: - """Yield import nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, ast.Import | ImportFrom): - yield child - elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): - yield from iter_import_nodes(child) - - def iter_imports(node: ast.Module) -> Iterator[Import]: """Yield import nodes and names.""" - for child in iter_import_nodes(node): - from_ = f"{child.module}" if isinstance(child, ImportFrom) else None + for child in mkapi.ast.iter_import_nodes(node): + from_ = f"{child.module}" if isinstance(child, ast.ImportFrom) else None for alias in child.names: name = alias.asname or alias.name fullname = f"{from_}.{alias.name}" if from_ else name - yield Import(child, name, None, fullname, from_) + yield Import(child, name, None, fullname, from_, None) @dataclass(repr=False) class Attribute(Object): # noqa: D101 - _node: Assign_ | FunctionDef_ | None # Needs FunctionDef_ for property. + _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None -def _get_pseudo_docstring(node: AST) -> str | None: - if not isinstance(node, Expr) or not isinstance(node.value, Constant): - return None - doc = node.value.value - return cleandoc(doc) if isinstance(doc, str) else None - - -def iter_assign_nodes(node: ast.Module | ClassDef) -> Iterator[Assign_]: - """Yield assign nodes.""" - assign_node: Assign_ | None = None - for child in ast.iter_child_nodes(node): - if isinstance(child, AnnAssign | Assign | TypeAlias): - if assign_node: - yield assign_node - child.__doc__ = None - assign_node = child - else: - if assign_node: - assign_node.__doc__ = _get_pseudo_docstring(child) - yield assign_node - assign_node = None - if assign_node: - assign_node.__doc__ = None - yield assign_node - - -def get_assign_name(node: Assign_) -> str | None: - """Return the name of the assign node.""" - if isinstance(node, AnnAssign) and isinstance(node.target, Name): - return node.target.id - if isinstance(node, Assign) and isinstance(node.targets[0], Name): - return node.targets[0].id - if isinstance(node, TypeAlias) and isinstance(node.name, Name): - return node.name.id - return None - - -def get_type(node: Assign_) -> ast.expr | None: - """Return a type annotation of the Assign or TypeAlias AST node.""" - if isinstance(node, AnnAssign): - return node.annotation - if isinstance(node, TypeAlias): - return node.value - return None - - -def iter_attributes(node: ast.Module | ClassDef) -> Iterator[Attribute]: +def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: """Yield assign nodes from the Module or Class AST node.""" - for assign in iter_assign_nodes(node): - if not (name := get_assign_name(assign)): + for assign in mkapi.ast.iter_assign_nodes(node): + if not (name := mkapi.ast.get_assign_name(assign)): continue - type_ = get_type(assign) - value = None if isinstance(assign, TypeAlias) else assign.value - type_params = assign.type_params if isinstance(assign, TypeAlias) else None + type_ = mkapi.ast.get_assign_type(assign) + value = None if isinstance(assign, ast.TypeAlias) else assign.value + type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None attr = Attribute(assign, name, assign.__doc__, type_, value, type_params) _merge_attribute_docstring(attr) yield attr @@ -182,41 +110,12 @@ class Parameter(Object): # noqa: D101 kind: _ParameterKind | None -PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { - P.POSITIONAL_ONLY: "posonlyargs", # before '/', list - P.POSITIONAL_OR_KEYWORD: "args", # normal, list - P.VAR_POSITIONAL: "vararg", # *args, arg or None - P.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list - P.VAR_KEYWORD: "kwarg", # **kwargs, arg or None -} - - -def _iter_parameters(node: FunctionDef_) -> Iterator[tuple[ast.arg, _ParameterKind]]: - for kind, attr in PARAMETER_KIND_DICT.items(): - if args := getattr(node.args, attr): - it = args if isinstance(args, list) else [args] - yield from ((arg, kind) for arg in it) - - -def _iter_defaults(node: FunctionDef_) -> Iterator[ast.expr | None]: - args = node.args - num_positional = len(args.posonlyargs) + len(args.args) - nones = [None] * num_positional - yield from [*nones, *args.defaults][-num_positional:] - yield from args.kw_defaults - - -def iter_parameters(node: FunctionDef_) -> Iterator[Parameter]: +def iter_parameters( + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> Iterator[Parameter]: """Yield parameters from the function node.""" - it = _iter_defaults(node) - for arg, kind in _iter_parameters(node): - default = None if kind in [P.VAR_POSITIONAL, P.VAR_KEYWORD] else next(it) - name = arg.arg - if kind is P.VAR_POSITIONAL: - name = f"*{name}" - if kind is P.VAR_KEYWORD: - name = f"**{name}" - yield Parameter(arg, name, None, arg.annotation, default, kind) + for arg, kind, default in mkapi.ast.iter_parameters(node): + yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) @dataclass(repr=False) @@ -229,7 +128,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({exc})" -def iter_raises(node: FunctionDef_) -> Iterator[Raise]: +def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" for child in ast.walk(node): if isinstance(child, ast.Raise) and child.exc: @@ -242,7 +141,7 @@ class Return(Object): # noqa: D101 type: ast.expr | None # # noqa: A003 -def get_return(node: FunctionDef_) -> Return: +def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: """Return a [Return] instance.""" return Return(node.returns, "", None, node.returns) @@ -267,14 +166,14 @@ def get_fullname(self) -> str: # noqa: D102 @dataclass(repr=False) class Function(Callable): # noqa: D101 - _node: FunctionDef_ + _node: ast.FunctionDef | ast.AsyncFunctionDef returns: Return def get(self, name: str) -> Parameter | None: # noqa: D102 return self.get_parameter(name) -def _set_parent(obj: Class | Module) -> None: +def _set_parent(obj: Module | Class) -> None: for cls in obj.classes: cls.parent = obj for func in obj.functions: @@ -283,7 +182,7 @@ def _set_parent(obj: Class | Module) -> None: @dataclass(repr=False) class Class(Callable): # noqa: D101 - _node: ClassDef + _node: ast.ClassDef bases: list[Class] attributes: list[Attribute] classes: list[Class] @@ -315,15 +214,8 @@ def get(self, name: str) -> Parameter | Attribute | Class | Function | None: # return None -def iter_callable_nodes(node: ast.Module | ClassDef) -> Iterator[Def]: - """Yield callable nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): - yield child - - def _get_callable_args( - node: Def, + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, ) -> tuple[ str, str | None, @@ -334,47 +226,50 @@ def _get_callable_args( ]: name = node.name docstring = ast.get_docstring(node) - parameters = [] if isinstance(node, ClassDef) else list(iter_parameters(node)) + parameters = [] if isinstance(node, ast.ClassDef) else list(iter_parameters(node)) decorators = node.decorator_list type_params = node.type_params - raises = [] if isinstance(node, ClassDef) else list(iter_raises(node)) + raises = [] if isinstance(node, ast.ClassDef) else list(iter_raises(node)) return name, docstring, parameters, decorators, type_params, raises -def iter_callables(node: ast.Module | ClassDef) -> Iterator[Class | Function]: +def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" - for def_node in iter_callable_nodes(node): - args = _get_callable_args(def_node) - if isinstance(def_node, ClassDef): - attrs = list(iter_attributes(def_node)) - classes, functions = get_callables(def_node) + for callable_node in mkapi.ast.iter_callable_nodes(node): + args = _get_callable_args(callable_node) + if isinstance(callable_node, ast.ClassDef): + attrs = list(iter_attributes(callable_node)) + classes, functions = get_callables(callable_node) bases: list[Class] = [] - yield Class(def_node, *args, None, bases, attrs, classes, functions) + yield Class(callable_node, *args, None, bases, attrs, classes, functions) else: - yield Function(def_node, *args, None, get_return(def_node)) + yield Function(callable_node, *args, None, get_return(callable_node)) -def get_callables(node: ast.Module | ClassDef) -> tuple[list[Class], list[Function]]: +def get_callables( + node: ast.Module | ast.ClassDef, +) -> tuple[list[Class], list[Function]]: """Return a tuple of (list[Class], list[Function]).""" classes: list[Class] = [] functions: list[Function] = [] - for callable_ in iter_callables(node): - if isinstance(callable_, Class): - classes.append(callable_) + for callable_node in iter_callables(node): + if isinstance(callable_node, Class): + classes.append(callable_node) else: - functions.append(callable_) + functions.append(callable_node) return classes, functions @dataclass(repr=False) class Module(Object): # noqa: D101 + _node: ast.Module docstring: str | Docstring | None imports: list[Import] attributes: list[Attribute] classes: list[Class] functions: list[Function] - source: str + source: str | None kind: str def __post_init__(self) -> None: @@ -384,6 +279,12 @@ def __post_init__(self) -> None: def get_fullname(self) -> str: # noqa: D102 return self.name + def get_source(self, maxline: int | None = None) -> str | None: + """Return the source of the module.""" + if not self.source: + return None + return "\n".join(self.source.split("\n")[:maxline]) + def get_import(self, name: str) -> Import | None: # noqa: D102 return get_by_name(self.imports, name) @@ -407,42 +308,46 @@ def get(self, name: str) -> Import | Attribute | Class | Function | None: # noq return obj return None - def get_source(self) -> str: - """Return the source code.""" - return _get_module_source(self.name) if self.name else "" - - -CACHE_MODULE_NODE: dict[str, tuple[float, ast.Module, str]] = {} -CACHE_MODULE: dict[str, Module | None] = {} - -def _get_module_node(name: str) -> ast.Module | None: - """Return a [ast.Module] node by the name.""" +def get_module_path(name: str) -> Path | None: + """Return the source path of the module name.""" try: - spec = find_spec(name) + spec = importlib.util.find_spec(name) except ModuleNotFoundError: return None - if not spec or not spec.origin: + if not spec or not hasattr(spec, "origin") or not spec.origin: return None path = Path(spec.origin) if not path.exists(): # for builtin, frozen return None + return path + + +CACHE_MODULE: dict[str, tuple[Module | None, float]] = {} + + +def get_module(name: str, *, postprocess: bool = True) -> Module | None: + """Return a [Module] instance by the name.""" + if name in CACHE_MODULE and not CACHE_MODULE[name][0]: + return None + if not (path := get_module_path(name)): + CACHE_MODULE[name] = (None, 0) + return None mtime = path.stat().st_mtime - if name in CACHE_MODULE_NODE and mtime == CACHE_MODULE_NODE[name][0]: - return CACHE_MODULE_NODE[name][1] + if name in CACHE_MODULE and mtime == CACHE_MODULE[name][1]: + return CACHE_MODULE[name][0] with path.open("r", encoding="utf-8") as f: source = f.read() node = ast.parse(source) - CACHE_MODULE_NODE[name] = (mtime, node, source) - if name in CACHE_MODULE: - del CACHE_MODULE[name] - return node - - -def _get_module_source(name: str) -> str: - if name in CACHE_MODULE_NODE: - return CACHE_MODULE_NODE[name][2] - return "" + CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. + module = _get_module_from_node(node) + CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. + CACHE_MODULE[name] = (module, mtime) + module.name = name + module.source = source + if postprocess: + _postprocess(module) + return module def _get_module_from_node(node: ast.Module) -> Module: @@ -454,22 +359,22 @@ def _get_module_from_node(node: ast.Module) -> Module: return Module(node, "", docstring, imports, attrs, classes, functions, "", "") -def get_module(name: str) -> Module | None: - """Return a [Module] instance by the name.""" - if not (node := _get_module_node(name)): - CACHE_MODULE[name] = None - return None - # `_get_module_node` deletes `name` key in CACHE_MODULE if source was old. - if name in CACHE_MODULE: - return CACHE_MODULE[name] - CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. - module = _get_module_from_node(node) - CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. - module.name = name - module.source = _get_module_source(name) # Set from a global cache. - _postprocess(module) - CACHE_MODULE[name] = module - return module +def set_import_object(module: Module) -> None: + """Set import object.""" + for import_ in module.imports: + _set_import_object(import_) + + +def _set_import_object(import_: Import) -> None: + if obj := get_module(import_.fullname): + import_.object = obj + return + if "." not in import_.fullname: + return + module_name, name = import_.fullname.rsplit(".", maxsplit=1) + module = get_module(module_name) + if module and isinstance(obj := module.get(name), Class | Function | Attribute): + import_.object = obj CACHE_OBJECT: dict[str, Module | Class | Function] = {} @@ -517,7 +422,7 @@ def _to_expr(name: str) -> ast.expr: expr = ast.parse(name).body[0] if isinstance(expr, ast.Expr): return expr.value - return Constant(value=name) + return ast.Constant(value=name) def _merge_attribute_docstring(obj: Attribute) -> None: @@ -535,13 +440,14 @@ def _is_property(obj: Function) -> bool: def _move_property(obj: Class) -> None: funcs: list[Function] = [] for func in obj.functions: - if not _is_property(func): + node = func._node # noqa: SLF001 + if isinstance(node, ast.AsyncFunctionDef) or not _is_property(func): funcs.append(func) continue doc = func.docstring if isinstance(func.docstring, str) else "" type_ = func.returns.type type_params = func.type_params - attr = Attribute(func._node, func.name, doc, type_, None, type_params) # noqa: SLF001 + attr = Attribute(node, func.name, doc, type_, None, type_params) _merge_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs @@ -563,7 +469,7 @@ def _get_style(doc: str) -> Style: def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: if not obj.type and item.type: obj.type = _to_expr(item.type) - obj.docstring = item.description # Does item.description win? + obj.docstring = item.text # Does item.text win? def _new( @@ -620,7 +526,7 @@ def _postprocess(obj: Module | Class) -> None: _register_object(function) _merge_docstring(function) if isinstance(obj, Class): - del function.parameters[0] # Delete 'self' TODO: static method. + del function.parameters[0] # TODO: Delete 'self', static method. for cls in obj.classes: _postprocess(cls) _postprocess_class(cls) @@ -630,10 +536,10 @@ def _postprocess(obj: Module | Class) -> None: _ATTRIBUTE_ORDER_DICT = { type(None): 0, - AnnAssign: 1, - Assign: 2, - FunctionDef: 3, - AsyncFunctionDef: 4, + ast.AnnAssign: 1, + ast.Assign: 2, + ast.FunctionDef: 3, + ast.AsyncFunctionDef: 4, } diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py new file mode 100644 index 00000000..3fccb617 --- /dev/null +++ b/tests/docstrings/conftest.py @@ -0,0 +1,22 @@ +import sys +from pathlib import Path + +import pytest + +from mkapi.objects import get_module + + +@pytest.fixture(scope="module") +def google(): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + return get_module("examples.styles.example_google", postprocess=False) + + +@pytest.fixture(scope="module") +def numpy(): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + return get_module("examples.styles.example_numpy", postprocess=False) diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 51f78296..57950c80 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -1,139 +1,139 @@ -# from mkapi.docstrings import ( -# _iter_items, -# _iter_sections, -# iter_items, -# parse, -# split_item, -# split_section, -# split_without_name, -# ) -# from mkapi.objects import Module - - -# def test_split_section(): -# f = split_section -# for style in ["google", "numpy"]: -# assert f("A", style) == ("", "A") # type: ignore -# assert f("A:\n a\n b", "google") == ("A", "a\nb") -# assert f("A\n a\n b", "google") == ("", "A\n a\n b") -# assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") -# assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") -# assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") - - -# def test_iter_sections_short(): -# sections = list(_iter_sections("", "google")) -# assert sections == [] -# sections = list(_iter_sections("x", "google")) -# assert sections == [("", "x")] -# sections = list(_iter_sections("x\n", "google")) -# assert sections == [("", "x")] -# sections = list(_iter_sections("x\n\n", "google")) -# assert sections == [("", "x")] - - -# def test_iter_sections(google: Module): -# doc = google.docstring -# assert isinstance(doc, str) -# sections = list(_iter_sections(doc, "google")) -# assert len(sections) == 6 -# assert sections[0][1].startswith("Example Google") -# assert sections[0][1].endswith("indented text.") -# assert sections[1][0] == "Examples" -# assert sections[1][1].startswith("Examples can be") -# assert sections[1][1].endswith("google.py") -# assert sections[2][1].startswith("Section breaks") -# assert sections[2][1].endswith("section starts.") -# assert sections[3][0] == "Attributes" -# assert sections[3][1].startswith("module_level_") -# assert sections[3][1].endswith("with it.") -# assert sections[4][0] == "Todo" -# assert sections[4][1].startswith("* For") -# assert sections[4][1].endswith("extension") -# assert sections[5][1].startswith("..") -# assert sections[5][1].endswith(".html") - - -# def test_iter_items(google: Module): -# doc = google.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "google"))[1][1] -# items = list(_iter_items(section)) -# assert len(items) == 4 -# assert items[0].startswith("param1") -# assert items[1].startswith("param2") -# assert items[2].startswith("*args") -# assert items[3].startswith("**kwargs") -# doc = google.docstring -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "google"))[3][1] -# items = list(_iter_items(section)) -# assert len(items) == 1 -# assert items[0].startswith("module_") - - -# def test_split_item(google: Module): -# doc = google.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# sections = list(_iter_sections(doc, "google")) -# section = sections[1][1] -# items = list(_iter_items(section)) -# x = split_item(items[0], "google") -# assert x == ("param1", "int", "The first parameter.") -# x = split_item(items[1], "google") -# assert x[:2] == ("param2", ":obj:`str`, optional") -# assert x[2].endswith("should be indented.") -# x = split_item(items[2], "google") -# assert x == ("*args", "", "Variable length argument list.") -# section = sections[3][1] -# items = list(_iter_items(section)) -# x = split_item(items[0], "google") -# assert x[:2] == ("AttributeError", "") -# assert x[2].endswith("the interface.") - - -# def test_iter_items_class(google: Module): -# doc = google.get("ExampleClass").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "google"))[1][1] -# x = list(iter_items(section, "google")) -# assert x[0].name == "attr1" -# assert x[0].type == "str" -# assert x[0].description == "Description of `attr1`." -# assert x[1].name == "attr2" -# assert x[1].type == ":obj:`int`, optional" -# assert x[1].description == "Description of `attr2`." -# doc = google.get("ExampleClass").get("__init__").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "google"))[2][1] -# x = list(iter_items(section, "google")) -# assert x[0].name == "param1" -# assert x[0].type == "str" -# assert x[0].description == "Description of `param1`." -# assert x[1].description == "Description of `param2`. Multiple\nlines are supported." - - -# def test_split_without_name(google: Module): -# doc = google.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "google"))[2][1] -# x = split_without_name(section, "google") -# assert x[0] == "bool" -# assert x[1].startswith("True if") -# assert x[1].endswith(" }") - - -# def test_repr(google: Module): -# r = repr(parse(google.docstring, "google")) # type: ignore -# assert r == "Docstring(num_sections=6)" - - -# def test_iter_items_raises(google: Module): -# doc = google.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# name, section = list(_iter_sections(doc, "google"))[3] -# assert name == "Raises" -# items = list(iter_items(section, "google", name)) -# assert len(items) == 2 -# assert items[0].type == items[0].name == "AttributeError" -# assert items[1].type == items[1].name == "ValueError" +from mkapi.docstrings import ( + _iter_items, + _iter_sections, + iter_items, + parse, + split_item, + split_section, + split_without_name, +) +from mkapi.objects import Module + + +def test_split_section(): + f = split_section + for style in ["google", "numpy"]: + assert f("A", style) == ("", "A") # type: ignore + assert f("A:\n a\n b", "google") == ("A", "a\nb") + assert f("A\n a\n b", "google") == ("", "A\n a\n b") + assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") + assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") + assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +def test_iter_sections_short(): + sections = list(_iter_sections("", "google")) + assert sections == [] + sections = list(_iter_sections("x", "google")) + assert sections == [("", "x")] + sections = list(_iter_sections("x\n", "google")) + assert sections == [("", "x")] + sections = list(_iter_sections("x\n\n", "google")) + assert sections == [("", "x")] + + +def test_iter_sections(google: Module): + doc = google.docstring + assert isinstance(doc, str) + sections = list(_iter_sections(doc, "google")) + assert len(sections) == 6 + assert sections[0][1].startswith("Example Google") + assert sections[0][1].endswith("indented text.") + assert sections[1][0] == "Examples" + assert sections[1][1].startswith("Examples can be") + assert sections[1][1].endswith("google.py") + assert sections[2][1].startswith("Section breaks") + assert sections[2][1].endswith("section starts.") + assert sections[3][0] == "Attributes" + assert sections[3][1].startswith("module_level_") + assert sections[3][1].endswith("with it.") + assert sections[4][0] == "Todo" + assert sections[4][1].startswith("* For") + assert sections[4][1].endswith("extension") + assert sections[5][1].startswith("..") + assert sections[5][1].endswith(".html") + + +def test_iter_items(google: Module): + doc = google.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[1][1] + items = list(_iter_items(section)) + assert len(items) == 4 + assert items[0].startswith("param1") + assert items[1].startswith("param2") + assert items[2].startswith("*args") + assert items[3].startswith("**kwargs") + doc = google.docstring + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[3][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + + +def test_split_item(google: Module): + doc = google.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + sections = list(_iter_sections(doc, "google")) + section = sections[1][1] + items = list(_iter_items(section)) + x = split_item(items[0], "google") + assert x == ("param1", "int", "The first parameter.") + x = split_item(items[1], "google") + assert x[:2] == ("param2", ":obj:`str`, optional") + assert x[2].endswith("should be indented.") + x = split_item(items[2], "google") + assert x == ("*args", "", "Variable length argument list.") + section = sections[3][1] + items = list(_iter_items(section)) + x = split_item(items[0], "google") + assert x[:2] == ("AttributeError", "") + assert x[2].endswith("the interface.") + + +def test_iter_items_class(google: Module): + doc = google.get("ExampleClass").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[1][1] + x = list(iter_items(section, "google")) + assert x[0].name == "attr1" + assert x[0].type == "str" + assert x[0].text == "Description of `attr1`." + assert x[1].name == "attr2" + assert x[1].type == ":obj:`int`, optional" + assert x[1].text == "Description of `attr2`." + doc = google.get("ExampleClass").get("__init__").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[2][1] + x = list(iter_items(section, "google")) + assert x[0].name == "param1" + assert x[0].type == "str" + assert x[0].text == "Description of `param1`." + assert x[1].text == "Description of `param2`. Multiple\nlines are supported." + + +def test_split_without_name(google: Module): + doc = google.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[2][1] + x = split_without_name(section, "google") + assert x[0] == "bool" + assert x[1].startswith("True if") + assert x[1].endswith(" }") + + +def test_repr(google: Module): + r = repr(parse(google.docstring, "google")) # type: ignore + assert r == "Docstring(num_sections=6)" + + +def test_iter_items_raises(google: Module): + doc = google.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + name, section = list(_iter_sections(doc, "google"))[3] + assert name == "Raises" + items = list(iter_items(section, "google", name)) + assert len(items) == 2 + assert items[0].type == items[0].name == "AttributeError" + assert items[1].type == items[1].name == "ValueError" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index 3662ded9..ceac31d1 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -7,17 +7,17 @@ def test_iter_merged_items(): c = list(iter_merged_items(a, b)) assert c[0].name == "a" assert c[0].type == "str" - assert c[0].description == "item a" + assert c[0].text == "item a" assert c[1].name == "b" assert c[1].type == "int" assert c[2].name == "c" assert c[2].type == "list" -# def test_merge(google): -# a = parse(google.get("ExampleClass").docstring, "google") -# b = parse(google.get("ExampleClass").get("__init__").docstring, "google") -# doc = merge(a, b) -# assert doc -# assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] -# doc.sections[-1].description.endswith("with it.") +def test_merge(google): + a = parse(google.get("ExampleClass").docstring, "google") + b = parse(google.get("ExampleClass").get("__init__").docstring, "google") + doc = merge(a, b) + assert doc + assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] + doc.sections[-1].text.endswith("with it.") diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index 882305c4..9787e375 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -1,130 +1,130 @@ -# from mkapi.docstrings import ( -# _iter_items, -# _iter_sections, -# iter_items, -# split_item, -# split_section, -# split_without_name, -# ) -# from mkapi.objects import Module - - -# def test_split_section(): -# f = split_section -# assert f("A", "numpy") == ("", "A") # type: ignore -# assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") -# assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") -# assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") - - -# def test_iter_sections_short(): -# sections = list(_iter_sections("", "numpy")) -# assert sections == [] -# sections = list(_iter_sections("x", "numpy")) -# assert sections == [("", "x")] -# sections = list(_iter_sections("x\n", "numpy")) -# assert sections == [("", "x")] -# sections = list(_iter_sections("x\n\n", "numpy")) -# assert sections == [("", "x")] - - -# def test_iter_sections(numpy: Module): -# doc = numpy.docstring -# assert isinstance(doc, str) -# sections = list(_iter_sections(doc, "numpy")) -# assert len(sections) == 7 -# assert sections[0][1].startswith("Example NumPy") -# assert sections[0][1].endswith("equal length.") -# assert sections[1][0] == "Examples" -# assert sections[1][1].startswith("Examples can be") -# assert sections[1][1].endswith("numpy.py") -# assert sections[2][1].startswith("Section breaks") -# assert sections[2][1].endswith("be\nindented:") -# assert sections[3][0] == "Notes" -# assert sections[3][1].startswith("This is an") -# assert sections[3][1].endswith("surrounding text.") -# assert sections[4][1].startswith("If a section") -# assert sections[4][1].endswith("unindented text.") -# assert sections[5][0] == "Attributes" -# assert sections[5][1].startswith("module_level") -# assert sections[5][1].endswith("with it.") -# assert sections[6][1].startswith("..") -# assert sections[6][1].endswith(".rst.txt") - - -# def test_iter_items(numpy: Module): -# doc = numpy.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "numpy"))[1][1] -# items = list(_iter_items(section)) -# assert len(items) == 4 -# assert items[0].startswith("param1") -# assert items[1].startswith("param2") -# assert items[2].startswith("*args") -# assert items[3].startswith("**kwargs") -# doc = numpy.docstring -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "numpy"))[5][1] -# items = list(_iter_items(section)) -# assert len(items) == 1 -# assert items[0].startswith("module_") - - -# def test_split_item(numpy: Module): -# doc = numpy.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# sections = list(_iter_sections(doc, "numpy")) -# items = list(_iter_items(sections[1][1])) -# x = split_item(items[0], "numpy") -# assert x == ("param1", "int", "The first parameter.") -# x = split_item(items[1], "numpy") -# assert x[:2] == ("param2", ":obj:`str`, optional") -# assert x[2] == "The second parameter." -# x = split_item(items[2], "numpy") -# assert x == ("*args", "", "Variable length argument list.") -# items = list(_iter_items(sections[3][1])) -# x = split_item(items[0], "numpy") -# assert x[:2] == ("AttributeError", "") -# assert x[2].endswith("the interface.") - - -# def test_iter_items_class(numpy: Module): -# doc = numpy.get("ExampleClass").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "numpy"))[1][1] -# x = list(iter_items(section, "numpy")) -# assert x[0].name == "attr1" -# assert x[0].type == "str" -# assert x[0].description == "Description of `attr1`." -# assert x[1].name == "attr2" -# assert x[1].type == ":obj:`int`, optional" -# assert x[1].description == "Description of `attr2`." -# doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "numpy"))[2][1] -# x = list(iter_items(section, "numpy")) -# assert x[0].name == "param1" -# assert x[0].type == "str" -# assert x[0].description == "Description of `param1`." -# assert x[1].description == "Description of `param2`. Multiple\nlines are supported." - - -# def test_get_return(numpy: Module): -# doc = numpy.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# section = list(_iter_sections(doc, "numpy"))[2][1] -# x = split_without_name(section, "numpy") -# assert x[0] == "bool" -# assert x[1].startswith("True if") -# assert x[1].endswith(" }") - - -# def test_iter_items_raises(numpy: Module): -# doc = numpy.get("module_level_function").docstring # type: ignore -# assert isinstance(doc, str) -# name, section = list(_iter_sections(doc, "numpy"))[3] -# assert name == "Raises" -# items = list(iter_items(section, "numpy", name)) -# assert len(items) == 2 -# assert items[0].type == items[0].name == "AttributeError" -# assert items[1].type == items[1].name == "ValueError" +from mkapi.docstrings import ( + _iter_items, + _iter_sections, + iter_items, + split_item, + split_section, + split_without_name, +) +from mkapi.objects import Module + + +def test_split_section(): + f = split_section + assert f("A", "numpy") == ("", "A") # type: ignore + assert f("A\n---\na\nb", "numpy") == ("A", "a\nb") + assert f("A\n---\n a\n b", "numpy") == ("A", "a\nb") + assert f("A\n a\n b", "numpy") == ("", "A\n a\n b") + + +def test_iter_sections_short(): + sections = list(_iter_sections("", "numpy")) + assert sections == [] + sections = list(_iter_sections("x", "numpy")) + assert sections == [("", "x")] + sections = list(_iter_sections("x\n", "numpy")) + assert sections == [("", "x")] + sections = list(_iter_sections("x\n\n", "numpy")) + assert sections == [("", "x")] + + +def test_iter_sections(numpy: Module): + doc = numpy.docstring + assert isinstance(doc, str) + sections = list(_iter_sections(doc, "numpy")) + assert len(sections) == 7 + assert sections[0][1].startswith("Example NumPy") + assert sections[0][1].endswith("equal length.") + assert sections[1][0] == "Examples" + assert sections[1][1].startswith("Examples can be") + assert sections[1][1].endswith("numpy.py") + assert sections[2][1].startswith("Section breaks") + assert sections[2][1].endswith("be\nindented:") + assert sections[3][0] == "Notes" + assert sections[3][1].startswith("This is an") + assert sections[3][1].endswith("surrounding text.") + assert sections[4][1].startswith("If a section") + assert sections[4][1].endswith("unindented text.") + assert sections[5][0] == "Attributes" + assert sections[5][1].startswith("module_level") + assert sections[5][1].endswith("with it.") + assert sections[6][1].startswith("..") + assert sections[6][1].endswith(".rst.txt") + + +def test_iter_items(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[1][1] + items = list(_iter_items(section)) + assert len(items) == 4 + assert items[0].startswith("param1") + assert items[1].startswith("param2") + assert items[2].startswith("*args") + assert items[3].startswith("**kwargs") + doc = numpy.docstring + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[5][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + + +def test_split_item(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + sections = list(_iter_sections(doc, "numpy")) + items = list(_iter_items(sections[1][1])) + x = split_item(items[0], "numpy") + assert x == ("param1", "int", "The first parameter.") + x = split_item(items[1], "numpy") + assert x[:2] == ("param2", ":obj:`str`, optional") + assert x[2] == "The second parameter." + x = split_item(items[2], "numpy") + assert x == ("*args", "", "Variable length argument list.") + items = list(_iter_items(sections[3][1])) + x = split_item(items[0], "numpy") + assert x[:2] == ("AttributeError", "") + assert x[2].endswith("the interface.") + + +def test_iter_items_class(numpy: Module): + doc = numpy.get("ExampleClass").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[1][1] + x = list(iter_items(section, "numpy")) + assert x[0].name == "attr1" + assert x[0].type == "str" + assert x[0].text == "Description of `attr1`." + assert x[1].name == "attr2" + assert x[1].type == ":obj:`int`, optional" + assert x[1].text == "Description of `attr2`." + doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[2][1] + x = list(iter_items(section, "numpy")) + assert x[0].name == "param1" + assert x[0].type == "str" + assert x[0].text == "Description of `param1`." + assert x[1].text == "Description of `param2`. Multiple\nlines are supported." + + +def test_get_return(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[2][1] + x = split_without_name(section, "numpy") + assert x[0] == "bool" + assert x[1].startswith("True if") + assert x[1].endswith(" }") + + +def test_iter_items_raises(numpy: Module): + doc = numpy.get("module_level_function").docstring # type: ignore + assert isinstance(doc, str) + name, section = list(_iter_sections(doc, "numpy"))[3] + assert name == "Raises" + items = list(iter_items(section, "numpy", name)) + assert len(items) == 2 + assert items[0].type == items[0].name == "AttributeError" + assert items[1].type == items[1].name == "ValueError" diff --git a/tests/objects/test_baseclass.py b/tests/objects/test_baseclass.py index 216c5590..02457100 100644 --- a/tests/objects/test_baseclass.py +++ b/tests/objects/test_baseclass.py @@ -4,9 +4,7 @@ def test_baseclass(): - a = get_object("mkapi.objects.CACHE_OBJECT") - print(a) - assert 0 + pass # # cls = get_object("mkapi.plugins.MkAPIConfig") # # assert isinstance(cls, Class) # # base = cls._node.bases[0] diff --git a/tests/objects/test_cache.py b/tests/objects/test_cache.py index b4b8410b..ce9523ff 100644 --- a/tests/objects/test_cache.py +++ b/tests/objects/test_cache.py @@ -1,15 +1,8 @@ -from mkapi.objects import ( - CACHE_MODULE, - CACHE_MODULE_NODE, - CACHE_OBJECT, - get_module, - get_object, -) +from mkapi.objects import CACHE_MODULE, CACHE_OBJECT, get_module, get_object def test_cache(): CACHE_MODULE.clear() - CACHE_MODULE_NODE.clear() CACHE_OBJECT.clear() module = get_module("mkapi.objects") c = get_object("mkapi.objects.Object") @@ -18,3 +11,14 @@ def test_cache(): assert f assert c.get_module() is module assert f.get_module() is module + + +def test_get_module_check_mtime(): + m1 = get_module("mkdocs.structure.files") + m2 = get_module("mkdocs.structure.files") + assert m1 is m2 + CACHE_MODULE.clear() + m3 = get_module("mkdocs.structure.files") + m4 = get_module("mkdocs.structure.files") + assert m2 is not m3 + assert m3 is m4 diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py index 37512214..6205cb78 100644 --- a/tests/objects/test_import.py +++ b/tests/objects/test_import.py @@ -1,12 +1,10 @@ -import ast -from importlib.util import find_spec - -from mkapi.objects import _get_module_from_node, get_module, get_object +from mkapi.objects import CACHE_MODULE, get_module, set_import_object def test_import(): module = get_module("mkapi.plugins") - assert module + set_import_object(module) + for x in module.imports: - obj = get_object(x.fullname) - print(x.name, x.fullname, obj) + print(x.name, x.fullname, x.object) + # assert 0 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index 4b973b5d..d56b9fa1 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -2,11 +2,9 @@ import pytest -import mkapi.objects from mkapi.docstrings import Docstring from mkapi.objects import ( CACHE_MODULE, - CACHE_MODULE_NODE, Attribute, Class, Function, @@ -45,13 +43,9 @@ def test_move_property_to_attributes(google): @pytest.fixture() def module(): name = "examples.styles.example_google" - if name in CACHE_MODULE_NODE: - del CACHE_MODULE_NODE[name] if name in CACHE_MODULE: del CACHE_MODULE[name] - mkapi.objects.DEBUG_FOR_PYTEST = False - yield get_module(name) - mkapi.objects.DEBUG_FOR_PYTEST = True + return get_module(name) def test_merge_module_attrs(module: Module): diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index ec505896..f6206277 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -1,40 +1,23 @@ import ast -from ast import Module import pytest +import mkapi.ast import mkapi.objects -from mkapi.objects import ( - CACHE_MODULE_NODE, - _get_module_node, - get_module, - get_object, - iter_callable_nodes, - iter_import_nodes, - iter_imports, -) - - -def test_get_module_node(): - node = _get_module_node("mkdocs") - assert isinstance(node, Module) - - -def test_module_cache(): - node1 = _get_module_node("mkdocs") - node2 = _get_module_node("mkdocs") - assert node1 is node2 - module1 = get_module("mkapi") - module2 = get_module("mkapi") - assert module1 is module2 +from mkapi.ast import iter_callable_nodes, iter_import_nodes +from mkapi.objects import get_module, get_module_path, get_object, iter_imports @pytest.fixture(scope="module") def module(): - return _get_module_node("mkdocs.structure.files") + path = get_module_path("mkdocs.structure.files") + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) -def test_iter_import_nodes(module: Module): +def test_iter_import_nodes(module: ast.Module): node = next(iter_import_nodes(module)) assert isinstance(node, ast.ImportFrom) assert len(node.names) == 1 @@ -44,7 +27,7 @@ def test_iter_import_nodes(module: Module): assert alias.asname is None -def test_get_import_names(module: Module): +def test_get_import_names(module: ast.Module): it = iter_imports(module) names = {im.name: im.fullname for im in it} assert "logging" in names @@ -56,7 +39,7 @@ def test_get_import_names(module: Module): @pytest.fixture(scope="module") -def def_nodes(module: Module): +def def_nodes(module: ast.Module): return list(iter_callable_nodes(module)) @@ -66,13 +49,10 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): - assert _get_module_node("xxx") is None assert get_module("xxx") is None - assert mkapi.objects.CACHE_MODULE["xxx"] is None - assert "xxx" not in mkapi.objects.CACHE_MODULE_NODE - assert get_module("markdown") is not None + assert mkapi.objects.CACHE_MODULE["xxx"] == (None, 0) + assert get_module("markdown") assert "markdown" in mkapi.objects.CACHE_MODULE - assert "markdown" in mkapi.objects.CACHE_MODULE_NODE def test_repr(): @@ -87,6 +67,7 @@ def test_repr(): def test_get_module_source(): module = get_module("mkdocs.structure.files") assert module + assert module.source assert "class File" in module.source module = get_module("mkapi.plugins") assert module @@ -96,7 +77,9 @@ def test_get_module_source(): src = cls.get_source() assert src assert src.startswith("class MkAPIConfig") - assert "MkAPIPlugin" in module.get_source() + src = module.get_source() + assert src + assert "MkAPIPlugin" in src def test_get_module_from_object(): @@ -105,14 +88,3 @@ def test_get_module_from_object(): c = module.classes[1] m = c.get_module() assert module is m - - -def test_get_module_check_mtime(): - m1 = get_module("mkdocs.structure.files") - m2 = get_module("mkdocs.structure.files") - assert m1 is m2 - CACHE_MODULE_NODE.clear() - m3 = get_module("mkdocs.structure.files") - m4 = get_module("mkdocs.structure.files") - assert m2 is not m3 - assert m3 is m4 From cee7b8fe2b8a1c9728549835472e2e5d3e4eb84c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 8 Jan 2024 10:55:58 +0900 Subject: [PATCH 066/148] get_node_docstring --- src/mkapi/objects.py | 107 ++++++++++++++++---------------- tests/docstrings/conftest.py | 23 ++++--- tests/docstrings/test_google.py | 18 +++--- tests/docstrings/test_merge.py | 4 +- tests/docstrings/test_numpy.py | 16 ++--- tests/objects/test_cache.py | 24 ------- tests/objects/test_fullname.py | 37 ----------- tests/objects/test_merge.py | 9 +-- tests/objects/test_object.py | 40 +++++++++++- 9 files changed, 131 insertions(+), 147 deletions(-) delete mode 100644 tests/objects/test_cache.py delete mode 100644 tests/objects/test_fullname.py diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 2644d677..c6d0fc13 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -97,9 +97,7 @@ def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: type_ = mkapi.ast.get_assign_type(assign) value = None if isinstance(assign, ast.TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None - attr = Attribute(assign, name, assign.__doc__, type_, value, type_params) - _merge_attribute_docstring(attr) - yield attr + yield Attribute(assign, name, assign.__doc__, type_, value, type_params) @dataclass(repr=False) @@ -148,7 +146,8 @@ def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: @dataclass(repr=False) class Callable(Object): # noqa: D101 - docstring: str | Docstring | None + _node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef + docstring: Docstring | None parameters: list[Parameter] decorators: list[ast.expr] type_params: list[ast.type_param] @@ -163,6 +162,10 @@ def get_fullname(self) -> str: # noqa: D102 return f"{self.parent.get_fullname()}.{self.name}" return f"...{self.name}" + def get_node_docstring(self) -> str | None: + """Return the docstring of the node.""" + return ast.get_docstring(self._node) + @dataclass(repr=False) class Function(Callable): # noqa: D101 @@ -173,13 +176,6 @@ def get(self, name: str) -> Parameter | None: # noqa: D102 return self.get_parameter(name) -def _set_parent(obj: Module | Class) -> None: - for cls in obj.classes: - cls.parent = obj - for func in obj.functions: - func.parent = obj - - @dataclass(repr=False) class Class(Callable): # noqa: D101 _node: ast.ClassDef @@ -188,11 +184,6 @@ class Class(Callable): # noqa: D101 classes: list[Class] functions: list[Function] - def __post_init__(self) -> None: - super().__post_init__() - _set_parent(self) - _move_property(self) - def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 return get_by_name(self.attributes, name) @@ -217,34 +208,29 @@ def get(self, name: str) -> Parameter | Attribute | Class | Function | None: # def _get_callable_args( node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, ) -> tuple[ - str, - str | None, list[Parameter], list[ast.expr], list[ast.type_param], list[Raise], ]: - name = node.name - docstring = ast.get_docstring(node) parameters = [] if isinstance(node, ast.ClassDef) else list(iter_parameters(node)) decorators = node.decorator_list - type_params = node.type_params raises = [] if isinstance(node, ast.ClassDef) else list(iter_raises(node)) - return name, docstring, parameters, decorators, type_params, raises + return parameters, decorators, type_params, raises def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" - for callable_node in mkapi.ast.iter_callable_nodes(node): - args = _get_callable_args(callable_node) - if isinstance(callable_node, ast.ClassDef): - attrs = list(iter_attributes(callable_node)) - classes, functions = get_callables(callable_node) + for child in mkapi.ast.iter_callable_nodes(node): + args = (child.name, None, *_get_callable_args(child), None) + if isinstance(child, ast.ClassDef): bases: list[Class] = [] - yield Class(callable_node, *args, None, bases, attrs, classes, functions) + attrs = list(iter_attributes(child)) + classes, functions = get_callables(child) + yield Class(child, *args, bases, attrs, classes, functions) else: - yield Function(callable_node, *args, None, get_return(callable_node)) + yield Function(child, *args, get_return(child)) def get_callables( @@ -264,7 +250,7 @@ def get_callables( @dataclass(repr=False) class Module(Object): # noqa: D101 _node: ast.Module - docstring: str | Docstring | None + docstring: Docstring | None imports: list[Import] attributes: list[Attribute] classes: list[Class] @@ -272,10 +258,6 @@ class Module(Object): # noqa: D101 source: str | None kind: str - def __post_init__(self) -> None: - super().__post_init__() - _set_parent(self) - def get_fullname(self) -> str: # noqa: D102 return self.name @@ -285,6 +267,10 @@ def get_source(self, maxline: int | None = None) -> str | None: return None return "\n".join(self.source.split("\n")[:maxline]) + def get_node_docstring(self) -> str | None: + """Return the docstring of the node.""" + return ast.get_docstring(self._node) + def get_import(self, name: str) -> Import | None: # noqa: D102 return get_by_name(self.imports, name) @@ -326,7 +312,7 @@ def get_module_path(name: str) -> Path | None: CACHE_MODULE: dict[str, tuple[Module | None, float]] = {} -def get_module(name: str, *, postprocess: bool = True) -> Module | None: +def get_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" if name in CACHE_MODULE and not CACHE_MODULE[name][0]: return None @@ -342,21 +328,19 @@ def get_module(name: str, *, postprocess: bool = True) -> Module | None: CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. module = _get_module_from_node(node) CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. - CACHE_MODULE[name] = (module, mtime) module.name = name module.source = source - if postprocess: - _postprocess(module) + _postprocess(module) + CACHE_MODULE[name] = (module, mtime) return module def _get_module_from_node(node: ast.Module) -> Module: """Return a [Module] instance from the [ast.Module] node.""" - docstring = ast.get_docstring(node) imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) - return Module(node, "", docstring, imports, attrs, classes, functions, "", "") + return Module(node, "", None, imports, attrs, classes, functions, "", "") def set_import_object(module: Module) -> None: @@ -444,7 +428,7 @@ def _move_property(obj: Class) -> None: if isinstance(node, ast.AsyncFunctionDef) or not _is_property(func): funcs.append(func) continue - doc = func.docstring if isinstance(func.docstring, str) else "" + doc = func.get_node_docstring() type_ = func.returns.type type_params = func.type_params attr = Attribute(node, func.name, doc, type_, None, type_params) @@ -500,9 +484,12 @@ def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: def _merge_docstring(obj: Module | Class | Function) -> None: """Merge [Object] and [Docstring].""" - sections: list[Section] = [] - if not (doc := obj.docstring) or not isinstance(doc, str): + if not (doc := obj.get_node_docstring()): return + + sections: list[Section] = [] + # if not (doc := obj.docstring) or not isinstance(doc, str): + # return style = _get_style(doc) docstring = docstrings.parse(doc, style) for section in docstring: @@ -521,30 +508,42 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.docstring = docstring +def _set_parent(obj: Module | Class) -> None: + for cls in obj.classes: + cls.parent = obj + for func in obj.functions: + func.parent = obj + + def _postprocess(obj: Module | Class) -> None: - for function in obj.functions: - _register_object(function) - _merge_docstring(function) - if isinstance(obj, Class): - del function.parameters[0] # TODO: Delete 'self', static method. + _set_parent(obj) + _register_object(obj) + _merge_docstring(obj) + if isinstance(obj, Class): + _move_property(obj) + for attr in obj.attributes: + _merge_attribute_docstring(attr) for cls in obj.classes: _postprocess(cls) _postprocess_class(cls) - _register_object(obj) - _merge_docstring(obj) + for func in obj.functions: + _register_object(func) + _merge_docstring(func) -_ATTRIBUTE_ORDER_DICT = { - type(None): 0, +ATTRIBUTE_ORDER_DICT = { ast.AnnAssign: 1, ast.Assign: 2, ast.FunctionDef: 3, - ast.AsyncFunctionDef: 4, + ast.TypeAlias: 4, } def _attribute_order(attr: Attribute) -> int: - return _ATTRIBUTE_ORDER_DICT.get(type(attr._node), 10) # type: ignore # noqa: SLF001 + node = attr._node # noqa: SLF001 + if node is None: + return 0 + return ATTRIBUTE_ORDER_DICT.get(type(node), 10) def _postprocess_class(cls: Class) -> None: diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index 3fccb617..bc729416 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -1,22 +1,29 @@ +import ast import sys from pathlib import Path import pytest -from mkapi.objects import get_module +from mkapi.objects import _get_module_from_node, get_module_path -@pytest.fixture(scope="module") -def google(): +def get_module(name): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) - return get_module("examples.styles.example_google", postprocess=False) + path = get_module_path(name) + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + node = ast.parse(source) + return _get_module_from_node(node) + + +@pytest.fixture(scope="module") +def google(): + return get_module("examples.styles.example_google") @pytest.fixture(scope="module") def numpy(): - path = str(Path(__file__).parent.parent) - if path not in sys.path: - sys.path.insert(0, str(path)) - return get_module("examples.styles.example_numpy", postprocess=False) + return get_module("examples.styles.example_numpy") diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 57950c80..26045b3e 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -33,7 +33,7 @@ def test_iter_sections_short(): def test_iter_sections(google: Module): - doc = google.docstring + doc = google.get_node_docstring() assert isinstance(doc, str) sections = list(_iter_sections(doc, "google")) assert len(sections) == 6 @@ -55,7 +55,7 @@ def test_iter_sections(google: Module): def test_iter_items(google: Module): - doc = google.get("module_level_function").docstring # type: ignore + doc = google.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] items = list(_iter_items(section)) @@ -64,7 +64,7 @@ def test_iter_items(google: Module): assert items[1].startswith("param2") assert items[2].startswith("*args") assert items[3].startswith("**kwargs") - doc = google.docstring + doc = google.get_node_docstring() assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[3][1] items = list(_iter_items(section)) @@ -73,7 +73,7 @@ def test_iter_items(google: Module): def test_split_item(google: Module): - doc = google.get("module_level_function").docstring # type: ignore + doc = google.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) sections = list(_iter_sections(doc, "google")) section = sections[1][1] @@ -93,7 +93,7 @@ def test_split_item(google: Module): def test_iter_items_class(google: Module): - doc = google.get("ExampleClass").docstring # type: ignore + doc = google.get("ExampleClass").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) @@ -103,7 +103,7 @@ def test_iter_items_class(google: Module): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].text == "Description of `attr2`." - doc = google.get("ExampleClass").get("__init__").docstring # type: ignore + doc = google.get("ExampleClass").get("__init__").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) @@ -114,7 +114,7 @@ def test_iter_items_class(google: Module): def test_split_without_name(google: Module): - doc = google.get("module_level_function").docstring # type: ignore + doc = google.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = split_without_name(section, "google") @@ -124,12 +124,12 @@ def test_split_without_name(google: Module): def test_repr(google: Module): - r = repr(parse(google.docstring, "google")) # type: ignore + r = repr(parse(google.get_node_docstring(), "google")) # type: ignore assert r == "Docstring(num_sections=6)" def test_iter_items_raises(google: Module): - doc = google.get("module_level_function").docstring # type: ignore + doc = google.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) name, section = list(_iter_sections(doc, "google"))[3] assert name == "Raises" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index ceac31d1..fbcda799 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -15,8 +15,8 @@ def test_iter_merged_items(): def test_merge(google): - a = parse(google.get("ExampleClass").docstring, "google") - b = parse(google.get("ExampleClass").get("__init__").docstring, "google") + a = parse(google.get("ExampleClass").get_node_docstring(), "google") + b = parse(google.get("ExampleClass").get("__init__").get_node_docstring(), "google") doc = merge(a, b) assert doc assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index 9787e375..58df8af0 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -29,7 +29,7 @@ def test_iter_sections_short(): def test_iter_sections(numpy: Module): - doc = numpy.docstring + doc = numpy.get_node_docstring() assert isinstance(doc, str) sections = list(_iter_sections(doc, "numpy")) assert len(sections) == 7 @@ -53,7 +53,7 @@ def test_iter_sections(numpy: Module): def test_iter_items(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore + doc = numpy.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] items = list(_iter_items(section)) @@ -62,7 +62,7 @@ def test_iter_items(numpy: Module): assert items[1].startswith("param2") assert items[2].startswith("*args") assert items[3].startswith("**kwargs") - doc = numpy.docstring + doc = numpy.get_node_docstring() assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[5][1] items = list(_iter_items(section)) @@ -71,7 +71,7 @@ def test_iter_items(numpy: Module): def test_split_item(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore + doc = numpy.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) sections = list(_iter_sections(doc, "numpy")) items = list(_iter_items(sections[1][1])) @@ -89,7 +89,7 @@ def test_split_item(numpy: Module): def test_iter_items_class(numpy: Module): - doc = numpy.get("ExampleClass").docstring # type: ignore + doc = numpy.get("ExampleClass").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) @@ -99,7 +99,7 @@ def test_iter_items_class(numpy: Module): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].text == "Description of `attr2`." - doc = numpy.get("ExampleClass").get("__init__").docstring # type: ignore + doc = numpy.get("ExampleClass").get("__init__").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) @@ -110,7 +110,7 @@ def test_iter_items_class(numpy: Module): def test_get_return(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore + doc = numpy.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = split_without_name(section, "numpy") @@ -120,7 +120,7 @@ def test_get_return(numpy: Module): def test_iter_items_raises(numpy: Module): - doc = numpy.get("module_level_function").docstring # type: ignore + doc = numpy.get("module_level_function").get_node_docstring() # type: ignore assert isinstance(doc, str) name, section = list(_iter_sections(doc, "numpy"))[3] assert name == "Raises" diff --git a/tests/objects/test_cache.py b/tests/objects/test_cache.py deleted file mode 100644 index ce9523ff..00000000 --- a/tests/objects/test_cache.py +++ /dev/null @@ -1,24 +0,0 @@ -from mkapi.objects import CACHE_MODULE, CACHE_OBJECT, get_module, get_object - - -def test_cache(): - CACHE_MODULE.clear() - CACHE_OBJECT.clear() - module = get_module("mkapi.objects") - c = get_object("mkapi.objects.Object") - f = get_object("mkapi.objects.Module.get_class") - assert c - assert f - assert c.get_module() is module - assert f.get_module() is module - - -def test_get_module_check_mtime(): - m1 = get_module("mkdocs.structure.files") - m2 = get_module("mkdocs.structure.files") - assert m1 is m2 - CACHE_MODULE.clear() - m3 = get_module("mkdocs.structure.files") - m4 = get_module("mkdocs.structure.files") - assert m2 is not m3 - assert m3 is m4 diff --git a/tests/objects/test_fullname.py b/tests/objects/test_fullname.py deleted file mode 100644 index 274f8b57..00000000 --- a/tests/objects/test_fullname.py +++ /dev/null @@ -1,37 +0,0 @@ -import ast - -from mkapi.objects import _get_module_from_node - -source = """ -class A: - def f(self): - pass - class C: - def g(self): - pass -""" - - -def test_parent(): - node = ast.parse(source) - module = _get_module_from_node(node) - module.name = "m" - a = module.get_class("A") - assert a - f = a.get_function("f") - assert f - c = a.get_class("C") - assert c - g = c.get_function("g") - assert g - assert g.parent is c - assert c.parent is a - assert f.parent is a - - -def test_get_fullname(google): - c = google.get_class("ExampleClass") - f = c.get_function("example_method") - assert c.get_fullname() == "examples.styles.example_google.ExampleClass" - name = "examples.styles.example_google.ExampleClass.example_method" - assert f.get_fullname() == name diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index d56b9fa1..627dc99f 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -97,17 +97,18 @@ def test_merge_generator(module: Module): def test_postprocess_class(module: Module): c = module.get_class("ExampleError") assert isinstance(c, Class) - assert len(c.parameters) == 2 + assert len(c.parameters) == 3 # with `self` at this state. assert len(c.docstring.sections) == 2 # type: ignore assert not c.functions c = module.get_class("ExampleClass") assert isinstance(c, Class) - assert len(c.parameters) == 3 + assert len(c.parameters) == 4 # with `self` at this state. assert len(c.docstring.sections) == 3 # type: ignore - assert ast.unparse(c.parameters[2].type) == "list[str]" # type: ignore + assert ast.unparse(c.parameters[3].type) == "list[str]" # type: ignore assert c.attributes[0].name == "attr1" f = c.get_function("example_method") - assert len(f.parameters) == 2 # type: ignore + assert f + assert len(f.parameters) == 3 # with `self` at this state. def test_postprocess_class_pep526(module: Module): diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index f6206277..17415bea 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -5,7 +5,14 @@ import mkapi.ast import mkapi.objects from mkapi.ast import iter_callable_nodes, iter_import_nodes -from mkapi.objects import get_module, get_module_path, get_object, iter_imports +from mkapi.objects import ( + CACHE_MODULE, + CACHE_OBJECT, + get_module, + get_module_path, + get_object, + iter_imports, +) @pytest.fixture(scope="module") @@ -88,3 +95,34 @@ def test_get_module_from_object(): c = module.classes[1] m = c.get_module() assert module is m + + +def test_get_fullname(google): + c = google.get_class("ExampleClass") + f = c.get_function("example_method") + assert c.get_fullname() == "examples.styles.example_google.ExampleClass" + name = "examples.styles.example_google.ExampleClass.example_method" + assert f.get_fullname() == name + + +def test_cache(): + CACHE_MODULE.clear() + CACHE_OBJECT.clear() + module = get_module("mkapi.objects") + c = get_object("mkapi.objects.Object") + f = get_object("mkapi.objects.Module.get_class") + assert c + assert f + assert c.get_module() is module + assert f.get_module() is module + + +def test_get_module_check_mtime(): + m1 = get_module("mkdocs.structure.files") + m2 = get_module("mkdocs.structure.files") + assert m1 is m2 + CACHE_MODULE.clear() + m3 = get_module("mkdocs.structure.files") + m4 = get_module("mkdocs.structure.files") + assert m2 is not m3 + assert m3 is m4 From 8df06f5acf2458ba70ac8c56483744ae1def5cc0 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 8 Jan 2024 11:43:52 +0900 Subject: [PATCH 067/148] object iter --- src/mkapi/objects.py | 165 ++++++++++++++++++++++--------------------- src/mkapi/utils.py | 6 +- 2 files changed, 87 insertions(+), 84 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index c6d0fc13..3e7da43c 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -149,14 +149,28 @@ class Callable(Object): # noqa: D101 _node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef docstring: Docstring | None parameters: list[Parameter] + raises: list[Raise] decorators: list[ast.expr] type_params: list[ast.type_param] - raises: list[Raise] parent: Class | Module | None - def get_parameter(self, name: str) -> Parameter | None: # noqa: D102 + def __iter__(self) -> Iterator[Parameter | Raise]: + """Yield member instances.""" + yield from self.parameters + yield from self.raises + + def get(self, name: str) -> Parameter | Raise | None: + """Return a member instance by the name.""" + return get_by_name(self, name) + + def get_parameter(self, name: str) -> Parameter | None: + """Return a [Parameter] instance by the name.""" return get_by_name(self.parameters, name) + def get_raise(self, name: str) -> Raise | None: + """Return a [Riase] instance by the name.""" + return get_by_name(self.raises, name) + def get_fullname(self) -> str: # noqa: D102 if self.parent: return f"{self.parent.get_fullname()}.{self.name}" @@ -168,56 +182,49 @@ def get_node_docstring(self) -> str | None: @dataclass(repr=False) -class Function(Callable): # noqa: D101 +class Function(Callable): + """Function class.""" + _node: ast.FunctionDef | ast.AsyncFunctionDef returns: Return - def get(self, name: str) -> Parameter | None: # noqa: D102 - return self.get_parameter(name) - @dataclass(repr=False) -class Class(Callable): # noqa: D101 +class Class(Callable): + """Class class.""" + _node: ast.ClassDef - bases: list[Class] attributes: list[Attribute] classes: list[Class] functions: list[Function] + bases: list[Class] - def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 + def __iter__(self) -> Iterator[Parameter | Attribute | Class | Function | Raise]: + """Yield member instances.""" + yield from super().__iter__() + yield from self.attributes + yield from self.classes + yield from self.functions + + def get_attribute(self, name: str) -> Attribute | None: + """Return an [Attribute] instance by the name.""" return get_by_name(self.attributes, name) - def get_class(self, name: str) -> Class | None: # noqa: D102 + def get_class(self, name: str) -> Class | None: + """Return a [Class] instance by the name.""" return get_by_name(self.classes, name) - def get_function(self, name: str) -> Function | None: # noqa: D102 + def get_function(self, name: str) -> Function | None: + """Return a [Function] instance by the name.""" return get_by_name(self.functions, name) - def get(self, name: str) -> Parameter | Attribute | Class | Function | None: # noqa: D102 - if obj := self.get_parameter(name): - return obj - if obj := self.get_attribute(name): - return obj - if obj := self.get_class(name): - return obj - if obj := self.get_function(name): - return obj - return None - def _get_callable_args( node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, -) -> tuple[ - list[Parameter], - list[ast.expr], - list[ast.type_param], - list[Raise], -]: +) -> tuple[list[Parameter], list[Raise], list[ast.expr], list[ast.type_param]]: parameters = [] if isinstance(node, ast.ClassDef) else list(iter_parameters(node)) - decorators = node.decorator_list - type_params = node.type_params raises = [] if isinstance(node, ast.ClassDef) else list(iter_raises(node)) - return parameters, decorators, type_params, raises + return parameters, raises, node.decorator_list, node.type_params def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function]: @@ -225,10 +232,10 @@ def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function for child in mkapi.ast.iter_callable_nodes(node): args = (child.name, None, *_get_callable_args(child), None) if isinstance(child, ast.ClassDef): - bases: list[Class] = [] attrs = list(iter_attributes(child)) classes, functions = get_callables(child) - yield Class(child, *args, bases, attrs, classes, functions) + bases: list[Class] = [] + yield Class(child, *args, attrs, classes, functions, bases) else: yield Function(child, *args, get_return(child)) @@ -237,8 +244,7 @@ def get_callables( node: ast.Module | ast.ClassDef, ) -> tuple[list[Class], list[Function]]: """Return a tuple of (list[Class], list[Function]).""" - classes: list[Class] = [] - functions: list[Function] = [] + classes, functions = [], [] for callable_node in iter_callables(node): if isinstance(callable_node, Class): classes.append(callable_node) @@ -248,7 +254,9 @@ def get_callables( @dataclass(repr=False) -class Module(Object): # noqa: D101 +class Module(Object): + """Module class.""" + _node: ast.Module docstring: Docstring | None imports: list[Import] @@ -271,29 +279,33 @@ def get_node_docstring(self) -> str | None: """Return the docstring of the node.""" return ast.get_docstring(self._node) - def get_import(self, name: str) -> Import | None: # noqa: D102 + def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: + """Yield member instances.""" + yield from self.imports + yield from self.attributes + yield from self.classes + yield from self.functions + + def get(self, name: str) -> Import | Attribute | Class | Function | None: + """Return a member instance by the name.""" + return get_by_name(self, name) + + def get_import(self, name: str) -> Import | None: + """Return an [Import] instance by the name.""" return get_by_name(self.imports, name) - def get_attribute(self, name: str) -> Attribute | None: # noqa: D102 + def get_attribute(self, name: str) -> Attribute | None: + """Return an [Attribute] instance by the name.""" return get_by_name(self.attributes, name) - def get_class(self, name: str) -> Class | None: # noqa: D102 + def get_class(self, name: str) -> Class | None: + """Return an [Class] instance by the name.""" return get_by_name(self.classes, name) - def get_function(self, name: str) -> Function | None: # noqa: D102 + def get_function(self, name: str) -> Function | None: + """Return an [Function] instance by the name.""" return get_by_name(self.functions, name) - def get(self, name: str) -> Import | Attribute | Class | Function | None: # noqa: D102 - if obj := self.get_import(name): - return obj - if obj := self.get_attribute(name): - return obj - if obj := self.get_class(name): - return obj - if obj := self.get_function(name): - return obj - return None - def get_module_path(name: str) -> Path | None: """Return the source path of the module name.""" @@ -343,24 +355,6 @@ def _get_module_from_node(node: ast.Module) -> Module: return Module(node, "", None, imports, attrs, classes, functions, "", "") -def set_import_object(module: Module) -> None: - """Set import object.""" - for import_ in module.imports: - _set_import_object(import_) - - -def _set_import_object(import_: Import) -> None: - if obj := get_module(import_.fullname): - import_.object = obj - return - if "." not in import_.fullname: - return - module_name, name = import_.fullname.rsplit(".", maxsplit=1) - module = get_module(module_name) - if module and isinstance(obj := module.get(name), Class | Function | Attribute): - import_.object = obj - - CACHE_OBJECT: dict[str, Module | Class | Function] = {} @@ -370,24 +364,15 @@ def _register_object(obj: Module | Class | Function) -> None: def get_object(fullname: str) -> Module | Class | Function | None: """Return a [Object] instance by the fullname.""" - if module := get_module(fullname): - return module if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] - if "." not in fullname: - return None - n = len(fullname.split(".")) - for maxsplit in range(1, n): - module_name, *_ = fullname.rsplit(".", maxsplit) - if module := get_module(module_name) and fullname in CACHE_OBJECT: + for maxsplit in range(fullname.count(".") + 1): + module_name = fullname.rsplit(".", maxsplit)[0] + if get_module(module_name) and fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] return None -# --------------------------------------------------------------------------------- -# Docsting -> Object -# --------------------------------------------------------------------------------- - # a1.b_2(c[d]) -> a1, b_2, c, d SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") @@ -554,3 +539,21 @@ def _postprocess_class(cls: Class) -> None: cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") # TODO: dataclass, bases + + +def set_import_object(module: Module) -> None: + """Set import object.""" + for import_ in module.imports: + _set_import_object(import_) + + +def _set_import_object(import_: Import) -> None: + if obj := get_module(import_.fullname): + import_.object = obj + return + if "." not in import_.fullname: + return + module_name, name = import_.fullname.rsplit(".", maxsplit=1) + module = get_module(module_name) + if module and isinstance(obj := module.get(name), Class | Function | Attribute): + import_.object = obj diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 520b0815..9ccbd225 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -155,14 +155,14 @@ def add_admonition(name: str, markdown: str) -> str: return "\n".join(lines) -def get_by_name[T](items: list[T], name: str, attr: str = "name") -> T | None: # noqa: D103 +def get_by_name[T](items: Iterable[T], name: str, attr: str = "name") -> T | None: # noqa: D103 for item in items: if getattr(item, attr, None) == name: return item return None -def get_by_kind[T](items: list[T], kind: str) -> T | None: # noqa: D103 +def get_by_kind[T](items: Iterable[T], kind: str) -> T | None: # noqa: D103 return get_by_name(items, kind, attr="kind") @@ -173,7 +173,7 @@ def del_by_name[T](items: list[T], name: str, attr: str = "name") -> None: # no return -def unique_names(a: list, b: list, attr: str = "name") -> list[str]: # noqa: D103 +def unique_names(a: Iterable, b: Iterable, attr: str = "name") -> list[str]: # noqa: D103 names = [getattr(x, attr) for x in a] for x in b: if (name := getattr(x, attr)) not in names: From 0da5947cf0f5e08ecd9fb57af07e83eda6f7e1b3 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 8 Jan 2024 16:18:21 +0900 Subject: [PATCH 068/148] ast transformer --- examples/imports/__init__.py | 0 examples/imports/a.py | 2 - examples/imports/b.py | 1 - examples/imports/c.py | 1 - src/mkapi/ast.py | 77 +++++++- src/mkapi/dataclasses.py | 34 ++++ src/mkapi/inspect.py | 49 ----- src/mkapi/objects.py | 171 ++++++++++++------ .../{test_baseclass.py => test_base.py} | 0 .../{test_callable.py => test_deco.py} | 0 tests/objects/test_import.py | 29 ++- tests/objects/test_iter.py | 43 +++++ tests/objects/test_object.py | 22 ++- .../test_transformer.py => test_ast.py} | 48 ++--- tests/test_dataclass.py | 42 +++++ 15 files changed, 377 insertions(+), 142 deletions(-) delete mode 100644 examples/imports/__init__.py delete mode 100644 examples/imports/a.py delete mode 100644 examples/imports/b.py delete mode 100644 examples/imports/c.py create mode 100644 src/mkapi/dataclasses.py delete mode 100644 src/mkapi/inspect.py rename tests/objects/{test_baseclass.py => test_base.py} (100%) rename tests/objects/{test_callable.py => test_deco.py} (100%) create mode 100644 tests/objects/test_iter.py rename tests/{inspect/test_transformer.py => test_ast.py} (64%) create mode 100644 tests/test_dataclass.py diff --git a/examples/imports/__init__.py b/examples/imports/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/imports/a.py b/examples/imports/a.py deleted file mode 100644 index 7f1f91a8..00000000 --- a/examples/imports/a.py +++ /dev/null @@ -1,2 +0,0 @@ -def f(): - pass \ No newline at end of file diff --git a/examples/imports/b.py b/examples/imports/b.py deleted file mode 100644 index 2e19619d..00000000 --- a/examples/imports/b.py +++ /dev/null @@ -1 +0,0 @@ -from a import f \ No newline at end of file diff --git a/examples/imports/c.py b/examples/imports/c.py deleted file mode 100644 index af09a438..00000000 --- a/examples/imports/c.py +++ /dev/null @@ -1 +0,0 @@ -from b import f \ No newline at end of file diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index d4aaadd8..4ee4acac 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -2,7 +2,6 @@ from __future__ import annotations import ast -import importlib.util from ast import ( AnnAssign, Assign, @@ -15,15 +14,15 @@ ImportFrom, Module, Name, + NodeTransformer, TypeAlias, ) from inspect import Parameter, cleandoc -from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from ast import AST - from collections.abc import Iterator + from collections.abc import Callable, Iterator from inspect import _ParameterKind @@ -85,11 +84,11 @@ def get_assign_type(node: AnnAssign | Assign | TypeAlias) -> ast.expr | None: PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { - Parameter.POSITIONAL_ONLY: "posonlyargs", # before '/', list - Parameter.POSITIONAL_OR_KEYWORD: "args", # normal, list - Parameter.VAR_POSITIONAL: "vararg", # *args, arg or None - Parameter.KEYWORD_ONLY: "kwonlyargs", # after '*' or '*args', list - Parameter.VAR_KEYWORD: "kwarg", # **kwargs, arg or None + Parameter.POSITIONAL_ONLY: "posonlyargs", + Parameter.POSITIONAL_OR_KEYWORD: "args", + Parameter.VAR_POSITIONAL: "vararg", + Parameter.KEYWORD_ONLY: "kwonlyargs", + Parameter.VAR_KEYWORD: "kwarg", } @@ -132,3 +131,65 @@ def iter_callable_nodes( for child in ast.iter_child_nodes(node): if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield child + + +class Transformer(NodeTransformer): # noqa: D101 + def _rename(self, name: str) -> Name: + return Name(id=f"__mkapi__.{name}") + + def visit_Name(self, node: Name) -> Name: # noqa: N802, D102 + return self._rename(node.id) + + def unparse(self, node: ast.AST) -> str: # noqa: D102 + node_ = ast.parse(ast.unparse(node)) # copy node for avoiding in-place rename. + return ast.unparse(self.visit(node_)) + + +class StringTransformer(Transformer): # noqa: D101 + def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 + if isinstance(node.value, str): + return self._rename(node.value) + return node + + +def _iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: + """Yield identifiers as a tuple of (code, isidentifier).""" + start = 0 + while start < len(source): + index = source.find("__mkapi__.", start) + if index == -1: + yield source[start:], False + return + if index != 0: + yield source[start:index], False + start = stop = index + 10 # 10 == len("__mkapi__.") + while stop < len(source): + c = source[stop] + if c == "." or c.isdigit() or c.isidentifier(): + stop += 1 + else: + break + yield source[start:stop], True + start = stop + + +def iter_identifiers(node: ast.AST) -> Iterator[str]: + """Yield identifiers.""" + source = StringTransformer().unparse(node) + for code, isidentifier in _iter_identifiers(source): + if isidentifier: + yield code + + +def _unparse(node: ast.AST, callback: Callable[[str], str]) -> Iterator[str]: + source = StringTransformer().unparse(node) + for code, isidentifier in _iter_identifiers(source): + if isidentifier: + yield callback(code) + else: + yield code + + +def unparse(node: ast.AST, callback: Callable[[str], str]) -> str: + """Unparse the AST node with a callback function.""" + return "".join(_unparse(node, callback)) diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py new file mode 100644 index 00000000..a968d0f5 --- /dev/null +++ b/src/mkapi/dataclasses.py @@ -0,0 +1,34 @@ +"""Dataclass function.""" +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING, Any + +from mkapi.ast import iter_identifiers + +if TYPE_CHECKING: + from mkapi.objects import Class, Iterator, Module + + +def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: + for deco in cls._node.decorator_list: + name = next(iter_identifiers(deco)) + if module.get_fullname(name) == "dataclasses.dataclass": + return deco + return None + + +def is_dataclass(cls: Class, module: Module) -> bool: + """Return True if the class is a dataclass.""" + return _get_dataclass_decorator(cls, module) is not None + + +def _iter_decorator_args(deco: ast.expr) -> Iterator[tuple[str, Any]]: + for child in ast.iter_child_nodes(deco): + if isinstance(child, ast.keyword): # noqa: SIM102 + if child.arg and isinstance(child.value, ast.Constant): + yield child.arg, child.value.value + + +def _get_decorator_args(deco: ast.expr) -> dict[str, Any]: + return dict(_get_decorator_args(deco)) diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py deleted file mode 100644 index 47fc68cb..00000000 --- a/src/mkapi/inspect.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Inspect.""" -from __future__ import annotations - -import ast -from ast import Constant, Name, NodeTransformer -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Iterator - - -class Transformer(NodeTransformer): # noqa: D101 - def _rename(self, name: str) -> Name: - return Name(id=f"__mkapi__.{name}") - - def visit_Name(self, node: Name) -> Name: # noqa: N802, D102 - return self._rename(node.id) - - def unparse(self, node: ast.expr | ast.type_param) -> str: # noqa: D102 - return ast.unparse(self.visit(node)) - - -class StringTransformer(Transformer): # noqa: D101 - def visit_Constant(self, node: Constant) -> Constant | Name: # noqa: N802, D102 - if isinstance(node.value, str): - return self._rename(node.value) - return node - - -def _iter_identifiers(source: str) -> Iterator[tuple[str, bool]]: - """Yield identifiers as a tuple of (code, isidentifier).""" - start = 0 - while start < len(source): - index = source.find("__mkapi__.", start) - if index == -1: - yield source[start:], False - return - else: - if index != 0: - yield source[start:index], False - start = end = index + 10 # 10 == len("__mkapi__.") - while end < len(source): - s = source[end] - if s == "." or s.isdigit() or s.isidentifier(): - end += 1 - else: - break - yield source[start:end], True - start = end diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 3e7da43c..69798859 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -4,7 +4,7 @@ import ast import importlib.util import re -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -28,15 +28,23 @@ class Object: # noqa: D101 name: str docstring: str | None - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - def __post_init__(self) -> None: # Set parent module name. self.__dict__["__module_name__"] = CURRENT_MODULE_NAME[0] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" + def __iter__(self) -> Iterator: + yield from [] + + def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: + """Yield AST expressions.""" + for obj in self: + if isinstance(obj, Object): + yield from obj.iter_exprs() + else: + yield obj + def get_module_name(self) -> str | None: """Return the module name.""" return self.__dict__["__module_name__"] @@ -59,13 +67,15 @@ def unparse(self) -> str: return ast.unparse(self._node) -@dataclass -class Import(Object): # noqa: D101 - _node: ast.Import | ast.ImportFrom = field(repr=False) - docstring: str | None = field(repr=False) +@dataclass(repr=False) +class Import(Object): + """Import class for [Module].""" + + _node: ast.Import | ast.ImportFrom + docstring: str | None fullname: str from_: str | None - object: Module | Class | Function | Attribute | None # noqa: A003 + object: Module | Class | Function | Attribute | Import | None # noqa: A003 def get_fullname(self) -> str: # noqa: D102 return self.fullname @@ -82,11 +92,26 @@ def iter_imports(node: ast.Module) -> Iterator[Import]: @dataclass(repr=False) -class Attribute(Object): # noqa: D101 +class Attribute(Object): + """Atrribute class for [Module] and [Class].""" + _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None + parent: Class | Module | None + + def __iter__(self) -> Iterator[ast.expr | ast.type_param]: + for expr in [self.type, self.default]: + if expr: + yield expr + if self.type_params: + yield from self.type_params + + def get_fullname(self) -> str: # noqa: D102 + if self.parent: + return f"{self.parent.get_fullname()}.{self.name}" + return f"...{self.name}" def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: @@ -97,16 +122,23 @@ def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: type_ = mkapi.ast.get_assign_type(assign) value = None if isinstance(assign, ast.TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None - yield Attribute(assign, name, assign.__doc__, type_, value, type_params) + yield Attribute(assign, name, assign.__doc__, type_, value, type_params, None) @dataclass(repr=False) -class Parameter(Object): # noqa: D101 +class Parameter(Object): + """Parameter class for [Class] and [Function].""" + _node: ast.arg | None type: ast.expr | None # # noqa: A003 default: ast.expr | None kind: _ParameterKind | None + def __iter__(self) -> Iterator[ast.expr]: + for expr in [self.type, self.default]: + if expr: + yield expr + def iter_parameters( node: ast.FunctionDef | ast.AsyncFunctionDef, @@ -117,7 +149,9 @@ def iter_parameters( @dataclass(repr=False) -class Raise(Object): # noqa: D101 +class Raise(Object): + """Raise class for [Class] and [Function].""" + _node: ast.Raise | None type: ast.expr | None # # noqa: A003 @@ -125,6 +159,10 @@ def __repr__(self) -> str: exc = ast.unparse(self.type) if self.type else "" return f"{self.__class__.__name__}({exc})" + def __iter__(self) -> Iterator[ast.expr]: + if self.type: + yield self.type + def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" @@ -134,10 +172,16 @@ def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise] @dataclass(repr=False) -class Return(Object): # noqa: D101 +class Return(Object): + """Return class for [Class] and [Function].""" + _node: ast.expr | None type: ast.expr | None # # noqa: A003 + def __iter__(self) -> Iterator[ast.expr]: + if self.type: + yield self.type + def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: """Return a [Return] instance.""" @@ -188,6 +232,11 @@ class Function(Callable): _node: ast.FunctionDef | ast.AsyncFunctionDef returns: Return + def __iter__(self) -> Iterator[Parameter | Raise | Return]: + """Yield member instances.""" + yield from super().__iter__() + yield self.returns + @dataclass(repr=False) class Class(Callable): @@ -264,10 +313,23 @@ class Module(Object): classes: list[Class] functions: list[Function] source: str | None - kind: str - - def get_fullname(self) -> str: # noqa: D102 - return self.name + kind: str | None + + def get_fullname(self, name: str | None = None) -> str | None: + """Return the fullname of the module. + + If the name is given, the fullname of member is returned, + possibly with an attribute. + """ + if not name: + return self.name + if obj := self.get(name): + return obj.get_fullname() + if "." in name: + name, attr = name.rsplit(".", maxsplit=1) + if obj := self.get(name): + return f"{obj.get_fullname()}.{attr}" + return None def get_source(self, maxline: int | None = None) -> str | None: """Return the source of the module.""" @@ -336,33 +398,38 @@ def get_module(name: str) -> Module | None: return CACHE_MODULE[name][0] with path.open("r", encoding="utf-8") as f: source = f.read() - node = ast.parse(source) - CURRENT_MODULE_NAME[0] = name # Set the module name in a global cache. - module = _get_module_from_node(node) - CURRENT_MODULE_NAME[0] = None # Remove the module name from a global cache. + CURRENT_MODULE_NAME[0] = name # Set the module name in the global cache. + module = _get_module_from_source(source) + CURRENT_MODULE_NAME[0] = None # Reset the module name in the global cache. + CACHE_MODULE[name] = (module, mtime) module.name = name module.source = source + module.kind = "package" if path.stem == "__init__" else "module" _postprocess(module) - CACHE_MODULE[name] = (module, mtime) return module +def _get_module_from_source(source: str) -> Module: + node = ast.parse(source) + return _get_module_from_node(node) + + def _get_module_from_node(node: ast.Module) -> Module: """Return a [Module] instance from the [ast.Module] node.""" imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) - return Module(node, "", None, imports, attrs, classes, functions, "", "") + return Module(node, "", None, imports, attrs, classes, functions, None, None) -CACHE_OBJECT: dict[str, Module | Class | Function] = {} +CACHE_OBJECT: dict[str, Module | Class | Function | Attribute] = {} -def _register_object(obj: Module | Class | Function) -> None: - CACHE_OBJECT[obj.get_fullname()] = obj +def _register_object(obj: Module | Class | Function | Attribute) -> None: + CACHE_OBJECT[obj.get_fullname()] = obj # type: ignore -def get_object(fullname: str) -> Module | Class | Function | None: +def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] @@ -373,6 +440,23 @@ def get_object(fullname: str) -> Module | Class | Function | None: return None +def set_import_object(module: Module) -> None: + """Set import object.""" + for import_ in module.imports: + _set_import_object(import_) + + +def _set_import_object(import_: Import) -> None: + if obj := get_module(import_.fullname): + import_.object = obj + return + if "." not in import_.fullname: + return + module_name, name = import_.fullname.rsplit(".", maxsplit=1) + if module := get_module(module_name): + import_.object = module.get(name) + + # a1.b_2(c[d]) -> a1, b_2, c, d SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") @@ -416,7 +500,7 @@ def _move_property(obj: Class) -> None: doc = func.get_node_docstring() type_ = func.returns.type type_params = func.type_params - attr = Attribute(node, func.name, doc, type_, None, type_params) + attr = Attribute(node, func.name, doc, type_, None, type_params, func.parent) _merge_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs @@ -446,7 +530,7 @@ def _new( name: str, ) -> Attribute | Parameter | Raise: if cls is Attribute: - return Attribute(None, name, None, None, None, []) + return Attribute(None, name, None, None, None, [], None) if cls is Parameter: return Parameter(None, name, None, None, None, None) if cls is Raise: @@ -473,8 +557,6 @@ def _merge_docstring(obj: Module | Class | Function) -> None: return sections: list[Section] = [] - # if not (doc := obj.docstring) or not isinstance(doc, str): - # return style = _get_style(doc) docstring = docstrings.parse(doc, style) for section in docstring: @@ -494,6 +576,8 @@ def _merge_docstring(obj: Module | Class | Function) -> None: def _set_parent(obj: Module | Class) -> None: + for attr in obj.attributes: + attr.parent = obj for cls in obj.classes: cls.parent = obj for func in obj.functions: @@ -506,11 +590,12 @@ def _postprocess(obj: Module | Class) -> None: _merge_docstring(obj) if isinstance(obj, Class): _move_property(obj) - for attr in obj.attributes: - _merge_attribute_docstring(attr) for cls in obj.classes: _postprocess(cls) _postprocess_class(cls) + for attr in obj.attributes: + _register_object(attr) + _merge_attribute_docstring(attr) for func in obj.functions: _register_object(func) _merge_docstring(func) @@ -539,21 +624,3 @@ def _postprocess_class(cls: Class) -> None: cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") # TODO: dataclass, bases - - -def set_import_object(module: Module) -> None: - """Set import object.""" - for import_ in module.imports: - _set_import_object(import_) - - -def _set_import_object(import_: Import) -> None: - if obj := get_module(import_.fullname): - import_.object = obj - return - if "." not in import_.fullname: - return - module_name, name = import_.fullname.rsplit(".", maxsplit=1) - module = get_module(module_name) - if module and isinstance(obj := module.get(name), Class | Function | Attribute): - import_.object = obj diff --git a/tests/objects/test_baseclass.py b/tests/objects/test_base.py similarity index 100% rename from tests/objects/test_baseclass.py rename to tests/objects/test_base.py diff --git a/tests/objects/test_callable.py b/tests/objects/test_deco.py similarity index 100% rename from tests/objects/test_callable.py rename to tests/objects/test_deco.py diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py index 6205cb78..cdafe0ac 100644 --- a/tests/objects/test_import.py +++ b/tests/objects/test_import.py @@ -1,10 +1,31 @@ -from mkapi.objects import CACHE_MODULE, get_module, set_import_object +from mkapi.objects import ( + Attribute, + Class, + Function, + Import, + Module, + get_module, + set_import_object, +) def test_import(): module = get_module("mkapi.plugins") + assert module set_import_object(module) - for x in module.imports: - print(x.name, x.fullname, x.object) - # assert 0 + i = module.get("annotations") + assert isinstance(i, Import) + assert isinstance(i.object, Attribute) + i = module.get("importlib") + assert isinstance(i, Import) + assert isinstance(i.object, Module) + i = module.get("Path") + assert isinstance(i, Import) + assert isinstance(i.object, Class) + i = module.get("get_files") + assert isinstance(i, Import) + assert isinstance(i.object, Function) + + # for x in module.imports: + # print(x.name, x.fullname, x.object) diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py new file mode 100644 index 00000000..c495262b --- /dev/null +++ b/tests/objects/test_iter.py @@ -0,0 +1,43 @@ +import ast + +import pytest + +from mkapi.objects import Module, Parameter, Return, get_module + + +@pytest.fixture() +def module(): + module = get_module("mkapi.objects") + assert module + return module + + +def test_iter(module: Module): + names = [o.name for o in module] + assert "Style" in names + assert "CACHE_MODULE" in names + assert "Class" in names + assert "get_object" in names + + +def test_empty(module: Module): + obj = module.get("Style") + assert obj + assert list(obj) == [] + + +def test_func(module: Module): + func = module.get("_get_callable_args") + assert func + objs = list(func) + assert isinstance(objs[0], Parameter) + assert isinstance(objs[1], Return) + + +def test_iter_exprs(module: Module): + func = module.get("_get_module_from_node") + assert func + exprs = list(func.iter_exprs()) + assert len(exprs) == 2 + assert ast.unparse(exprs[0]) == "ast.Module" + assert ast.unparse(exprs[1]) == "Module" diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 17415bea..2f219be5 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -112,9 +112,13 @@ def test_cache(): c = get_object("mkapi.objects.Object") f = get_object("mkapi.objects.Module.get_class") assert c - assert f assert c.get_module() is module + assert f assert f.get_module() is module + CACHE_OBJECT.clear() + assert not get_object("mkapi.objects.Module.get_class") + CACHE_MODULE.clear() + assert get_object("mkapi.objects.Module.get_class") def test_get_module_check_mtime(): @@ -126,3 +130,19 @@ def test_get_module_check_mtime(): m4 = get_module("mkdocs.structure.files") assert m2 is not m3 assert m3 is m4 + + +def test_module_kind(): + module = get_module("mkapi") + assert module + assert module.kind == "package" + module = get_module("mkapi.objects") + assert module + assert module.kind == "module" + + +def test_get_fullname_with_attr(): + module = get_module("mkapi.plugins") + assert module + name = module.get_fullname("config_options.Type") + assert name == "mkdocs.config.config_options.Type" diff --git a/tests/inspect/test_transformer.py b/tests/test_ast.py similarity index 64% rename from tests/inspect/test_transformer.py rename to tests/test_ast.py index 83cd480e..0566e27f 100644 --- a/tests/inspect/test_transformer.py +++ b/tests/test_ast.py @@ -1,45 +1,37 @@ import ast -import pytest +from mkapi.ast import StringTransformer, _iter_identifiers, unparse -from mkapi.inspect import StringTransformer, _iter_identifiers -from mkapi.objects import ( - Function, - Module, - get_module, -) - -def _unparse(src: str) -> str: +def _ast(src: str) -> ast.expr: expr = ast.parse(src).body[0] assert isinstance(expr, ast.Expr) - return StringTransformer().unparse(expr.value) + return expr.value + + +def _unparse(src: str) -> str: + return StringTransformer().unparse(_ast(src)) -def test_parse_expr_name(): +def test_expr_name(): assert _unparse("a") == "__mkapi__.a" -def test_parse_expr_subscript(): +def test_expr_subscript(): assert _unparse("a[b]") == "__mkapi__.a[__mkapi__.b]" -def test_parse_expr_attribute(): +def test_expr_attribute(): assert _unparse("a.b") == "__mkapi__.a.b" assert _unparse("a.b.c") == "__mkapi__.a.b.c" assert _unparse("a().b[0].c()") == "__mkapi__.a().b[0].c()" assert _unparse("a(b.c[d])") == "__mkapi__.a(__mkapi__.b.c[__mkapi__.d])" -def test_parse_expr_str(): +def test_expr_str(): assert _unparse("list['X.Y']") == "__mkapi__.list[__mkapi__.X.Y]" -@pytest.fixture(scope="module") -def module(): - return get_module("mkapi.objects") - - def test_iter_identifiers(): x = list(_iter_identifiers("x, __mkapi__.a.b0[__mkapi__.c], y")) assert len(x) == 5 @@ -62,8 +54,16 @@ def test_iter_identifiers(): assert x[1] == ("α.β.γ", True) # noqa: RUF001 -def test_functions(module: Module): - func = module.get("_get_callable_args") - assert isinstance(func, Function) - type_ = func.parameters[0].type - assert isinstance(type_, ast.expr) +def test_unparse(): + def callback(s: str) -> str: + return f"<{s}>" + + def f(s: str) -> str: + return unparse(_ast(s), callback) + + assert f("a") == "" + assert f("a.b.c") == "" + assert f("a.b[c].d(e)") == "[].d()" + assert f("a | b.c | d") == " | | " + assert f("list[A]") == "[]" + assert f("list['A']") == "[]" diff --git a/tests/test_dataclass.py b/tests/test_dataclass.py new file mode 100644 index 00000000..ed3f6aff --- /dev/null +++ b/tests/test_dataclass.py @@ -0,0 +1,42 @@ +import ast + +import mkapi.ast +from mkapi.dataclasses import ( + _get_dataclass_decorator, + _iter_decorator_args, + is_dataclass, +) +from mkapi.objects import _get_module_from_source, get_module + +source = """ +import dataclasses +from dataclasses import dataclass, field +@f() +@dataclasses.dataclass +@g +class A: + pass +@dataclass(init=True,repr=False) +class B: + x: list[A]=field(init=False) + y: int + z: str=field() +""" + + +def test_decorator_arg(): + module = _get_module_from_source(source) + cls = module.get_class("A") + assert cls + assert is_dataclass(cls, module) + deco = _get_dataclass_decorator(cls, module) + assert deco + assert not list(_iter_decorator_args(deco)) + cls = module.get_class("B") + assert cls + assert is_dataclass(cls, module) + deco = _get_dataclass_decorator(cls, module) + assert deco + deco_dict = dict(_iter_decorator_args(deco)) + assert deco_dict["init"] + assert not deco_dict["repr"] From 12650f4ea8a790cdb58d3110cce8b16b0753364e Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 8 Jan 2024 21:44:04 +0900 Subject: [PATCH 069/148] inspect --- examples_old/__init__.py | 1 + {examples => examples_old}/cls/__init__.py | 0 {examples => examples_old}/cls/abc.py | 0 {examples => examples_old}/cls/decorated.py | 0 {examples => examples_old}/cls/decorator.py | 0 {examples => examples_old}/cls/inherit.py | 0 .../cls/member_order_base.py | 0 .../cls/member_order_sub.py | 0 {examples => examples_old}/cls/method.py | 0 examples_old/custom.py | 11 + examples_old/docs/1.md | 7 + examples_old/docs/2.md | 7 + examples_old/docs/api/mkapi.objects.md | 1 + .../docs/api/mkdocs.commands.build.md | 1 + .../docs/api/mkdocs.commands.get_deps.md | 1 + .../docs/api/mkdocs.commands.gh_deploy.md | 1 + examples_old/docs/api/mkdocs.commands.md | 1 + examples_old/docs/api/mkdocs.commands.new.md | 1 + .../docs/api/mkdocs.commands.serve.md | 1 + examples_old/docs/api/mkdocs.config.base.md | 1 + .../docs/api/mkdocs.config.config_options.md | 1 + .../docs/api/mkdocs.config.defaults.md | 1 + examples_old/docs/api/mkdocs.config.md | 1 + examples_old/docs/api/mkdocs.contrib.md | 1 + .../docs/api/mkdocs.contrib.search.md | 1 + .../api/mkdocs.contrib.search.search_index.md | 1 + examples_old/docs/api/mkdocs.exceptions.md | 1 + examples_old/docs/api/mkdocs.livereload.md | 1 + examples_old/docs/api/mkdocs.localization.md | 1 + examples_old/docs/api/mkdocs.md | 1 + examples_old/docs/api/mkdocs.plugins.md | 1 + .../docs/api/mkdocs.structure.files.md | 1 + examples_old/docs/api/mkdocs.structure.md | 1 + examples_old/docs/api/mkdocs.structure.nav.md | 1 + .../docs/api/mkdocs.structure.pages.md | 1 + examples_old/docs/api/mkdocs.structure.toc.md | 1 + examples_old/docs/api/mkdocs.theme.md | 1 + examples_old/docs/api/mkdocs.themes.md | 1 + examples_old/docs/api/mkdocs.themes.mkdocs.md | 1 + .../docs/api/mkdocs.themes.readthedocs.md | 1 + .../docs/api/mkdocs.utils.babel_stub.md | 1 + examples_old/docs/api/mkdocs.utils.cache.md | 1 + examples_old/docs/api/mkdocs.utils.filters.md | 1 + examples_old/docs/api/mkdocs.utils.md | 1 + examples_old/docs/api/mkdocs.utils.meta.md | 1 + .../docs/api/mkdocs.utils.templates.md | 1 + examples_old/docs/api/mkdocs.utils.yaml.md | 1 + examples_old/docs/api2/mkapi.config.md | 1 + examples_old/docs/api2/mkapi.converter.md | 1 + examples_old/docs/api2/mkapi.docstrings.md | 1 + examples_old/docs/api2/mkapi.filter.md | 1 + examples_old/docs/api2/mkapi.inspect.md | 1 + examples_old/docs/api2/mkapi.link.md | 1 + examples_old/docs/api2/mkapi.md | 1 + examples_old/docs/api2/mkapi.nodes.md | 1 + examples_old/docs/api2/mkapi.objects.md | 1 + examples_old/docs/api2/mkapi.pages.md | 1 + examples_old/docs/api2/mkapi.plugins.md | 1 + examples_old/docs/api2/mkapi.renderer.md | 1 + examples_old/docs/api2/mkapi.utils.md | 1 + examples_old/docs/custom.css | 3 + examples_old/docs/index.md | 13 + {examples => examples_old}/inherit.py | 0 {examples => examples_old}/inherit_comment.py | 0 {examples => examples_old}/inspect.py | 0 {examples => examples_old}/link/__init__.py | 0 {examples => examples_old}/link/fullname.py | 0 {examples => examples_old}/link/qualname.py | 0 {examples => examples_old}/meta.py | 0 examples_old/mkdocs.yml | 36 ++ examples_old/styles/__init__.py | 4 + examples_old/styles/example_google.py | 314 ++++++++++++++++ examples_old/styles/example_numpy.py | 355 ++++++++++++++++++ .../test_field.py => src/mkapi/inspect.py | 0 src/mkapi/objects.py | 56 ++- tests/objects/test_base.py | 15 - tests/objects/test_class.py | 12 + tests/objects/test_import.py | 37 +- tests/test_dataclass.py | 35 +- 79 files changed, 912 insertions(+), 42 deletions(-) create mode 100644 examples_old/__init__.py rename {examples => examples_old}/cls/__init__.py (100%) rename {examples => examples_old}/cls/abc.py (100%) rename {examples => examples_old}/cls/decorated.py (100%) rename {examples => examples_old}/cls/decorator.py (100%) rename {examples => examples_old}/cls/inherit.py (100%) rename {examples => examples_old}/cls/member_order_base.py (100%) rename {examples => examples_old}/cls/member_order_sub.py (100%) rename {examples => examples_old}/cls/method.py (100%) create mode 100644 examples_old/custom.py create mode 100644 examples_old/docs/1.md create mode 100644 examples_old/docs/2.md create mode 100644 examples_old/docs/api/mkapi.objects.md create mode 100644 examples_old/docs/api/mkdocs.commands.build.md create mode 100644 examples_old/docs/api/mkdocs.commands.get_deps.md create mode 100644 examples_old/docs/api/mkdocs.commands.gh_deploy.md create mode 100644 examples_old/docs/api/mkdocs.commands.md create mode 100644 examples_old/docs/api/mkdocs.commands.new.md create mode 100644 examples_old/docs/api/mkdocs.commands.serve.md create mode 100644 examples_old/docs/api/mkdocs.config.base.md create mode 100644 examples_old/docs/api/mkdocs.config.config_options.md create mode 100644 examples_old/docs/api/mkdocs.config.defaults.md create mode 100644 examples_old/docs/api/mkdocs.config.md create mode 100644 examples_old/docs/api/mkdocs.contrib.md create mode 100644 examples_old/docs/api/mkdocs.contrib.search.md create mode 100644 examples_old/docs/api/mkdocs.contrib.search.search_index.md create mode 100644 examples_old/docs/api/mkdocs.exceptions.md create mode 100644 examples_old/docs/api/mkdocs.livereload.md create mode 100644 examples_old/docs/api/mkdocs.localization.md create mode 100644 examples_old/docs/api/mkdocs.md create mode 100644 examples_old/docs/api/mkdocs.plugins.md create mode 100644 examples_old/docs/api/mkdocs.structure.files.md create mode 100644 examples_old/docs/api/mkdocs.structure.md create mode 100644 examples_old/docs/api/mkdocs.structure.nav.md create mode 100644 examples_old/docs/api/mkdocs.structure.pages.md create mode 100644 examples_old/docs/api/mkdocs.structure.toc.md create mode 100644 examples_old/docs/api/mkdocs.theme.md create mode 100644 examples_old/docs/api/mkdocs.themes.md create mode 100644 examples_old/docs/api/mkdocs.themes.mkdocs.md create mode 100644 examples_old/docs/api/mkdocs.themes.readthedocs.md create mode 100644 examples_old/docs/api/mkdocs.utils.babel_stub.md create mode 100644 examples_old/docs/api/mkdocs.utils.cache.md create mode 100644 examples_old/docs/api/mkdocs.utils.filters.md create mode 100644 examples_old/docs/api/mkdocs.utils.md create mode 100644 examples_old/docs/api/mkdocs.utils.meta.md create mode 100644 examples_old/docs/api/mkdocs.utils.templates.md create mode 100644 examples_old/docs/api/mkdocs.utils.yaml.md create mode 100644 examples_old/docs/api2/mkapi.config.md create mode 100644 examples_old/docs/api2/mkapi.converter.md create mode 100644 examples_old/docs/api2/mkapi.docstrings.md create mode 100644 examples_old/docs/api2/mkapi.filter.md create mode 100644 examples_old/docs/api2/mkapi.inspect.md create mode 100644 examples_old/docs/api2/mkapi.link.md create mode 100644 examples_old/docs/api2/mkapi.md create mode 100644 examples_old/docs/api2/mkapi.nodes.md create mode 100644 examples_old/docs/api2/mkapi.objects.md create mode 100644 examples_old/docs/api2/mkapi.pages.md create mode 100644 examples_old/docs/api2/mkapi.plugins.md create mode 100644 examples_old/docs/api2/mkapi.renderer.md create mode 100644 examples_old/docs/api2/mkapi.utils.md create mode 100644 examples_old/docs/custom.css create mode 100644 examples_old/docs/index.md rename {examples => examples_old}/inherit.py (100%) rename {examples => examples_old}/inherit_comment.py (100%) rename {examples => examples_old}/inspect.py (100%) rename {examples => examples_old}/link/__init__.py (100%) rename {examples => examples_old}/link/fullname.py (100%) rename {examples => examples_old}/link/qualname.py (100%) rename {examples => examples_old}/meta.py (100%) create mode 100644 examples_old/mkdocs.yml create mode 100644 examples_old/styles/__init__.py create mode 100644 examples_old/styles/example_google.py create mode 100644 examples_old/styles/example_numpy.py rename tests/inspect/test_field.py => src/mkapi/inspect.py (100%) delete mode 100644 tests/objects/test_base.py create mode 100644 tests/objects/test_class.py diff --git a/examples_old/__init__.py b/examples_old/__init__.py new file mode 100644 index 00000000..2926481d --- /dev/null +++ b/examples_old/__init__.py @@ -0,0 +1 @@ +"""Example package for MkAPI test.""" diff --git a/examples/cls/__init__.py b/examples_old/cls/__init__.py similarity index 100% rename from examples/cls/__init__.py rename to examples_old/cls/__init__.py diff --git a/examples/cls/abc.py b/examples_old/cls/abc.py similarity index 100% rename from examples/cls/abc.py rename to examples_old/cls/abc.py diff --git a/examples/cls/decorated.py b/examples_old/cls/decorated.py similarity index 100% rename from examples/cls/decorated.py rename to examples_old/cls/decorated.py diff --git a/examples/cls/decorator.py b/examples_old/cls/decorator.py similarity index 100% rename from examples/cls/decorator.py rename to examples_old/cls/decorator.py diff --git a/examples/cls/inherit.py b/examples_old/cls/inherit.py similarity index 100% rename from examples/cls/inherit.py rename to examples_old/cls/inherit.py diff --git a/examples/cls/member_order_base.py b/examples_old/cls/member_order_base.py similarity index 100% rename from examples/cls/member_order_base.py rename to examples_old/cls/member_order_base.py diff --git a/examples/cls/member_order_sub.py b/examples_old/cls/member_order_sub.py similarity index 100% rename from examples/cls/member_order_sub.py rename to examples_old/cls/member_order_sub.py diff --git a/examples/cls/method.py b/examples_old/cls/method.py similarity index 100% rename from examples/cls/method.py rename to examples_old/cls/method.py diff --git a/examples_old/custom.py b/examples_old/custom.py new file mode 100644 index 00000000..5b50f4f6 --- /dev/null +++ b/examples_old/custom.py @@ -0,0 +1,11 @@ +def on_config(config, mkapi): + print("Called with config and mkapi.") + return config + + +def page_title(module_name: str, depth: int, ispackage: bool) -> str: + return ".".join(module_name.split(".")[depth:]) + + +def section_title(package_name: str, depth: int) -> str: + return ".".join(package_name.split(".")[depth:]) diff --git a/examples_old/docs/1.md b/examples_old/docs/1.md new file mode 100644 index 00000000..3db15f64 --- /dev/null +++ b/examples_old/docs/1.md @@ -0,0 +1,7 @@ +# 3 + +## 1-1 + +## 1-2 + +## 1-3 {#123} diff --git a/examples_old/docs/2.md b/examples_old/docs/2.md new file mode 100644 index 00000000..839c1e48 --- /dev/null +++ b/examples_old/docs/2.md @@ -0,0 +1,7 @@ +# 2 + +## 2-1 + +## 2-2 + +## 2-3 diff --git a/examples_old/docs/api/mkapi.objects.md b/examples_old/docs/api/mkapi.objects.md new file mode 100644 index 00000000..c2704ca4 --- /dev/null +++ b/examples_old/docs/api/mkapi.objects.md @@ -0,0 +1 @@ +Module(mkapi.objects): 2664400669872 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.build.md b/examples_old/docs/api/mkdocs.commands.build.md new file mode 100644 index 00000000..2ac0698b --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.build.md @@ -0,0 +1 @@ +Module(mkdocs.commands.build): 2664410679584 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.get_deps.md b/examples_old/docs/api/mkdocs.commands.get_deps.md new file mode 100644 index 00000000..04bc48de --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.get_deps.md @@ -0,0 +1 @@ +Module(mkdocs.commands.get_deps): 2664410685728 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.gh_deploy.md b/examples_old/docs/api/mkdocs.commands.gh_deploy.md new file mode 100644 index 00000000..a1355084 --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.gh_deploy.md @@ -0,0 +1 @@ +Module(mkdocs.commands.gh_deploy): 2664410688224 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.md b/examples_old/docs/api/mkdocs.commands.md new file mode 100644 index 00000000..077aef8f --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.md @@ -0,0 +1 @@ +Module(mkdocs.commands): 2664410679632 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.new.md b/examples_old/docs/api/mkdocs.commands.new.md new file mode 100644 index 00000000..90e3c615 --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.new.md @@ -0,0 +1 @@ +Module(mkdocs.commands.new): 2664410690528 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.serve.md b/examples_old/docs/api/mkdocs.commands.serve.md new file mode 100644 index 00000000..80e13e8b --- /dev/null +++ b/examples_old/docs/api/mkdocs.commands.serve.md @@ -0,0 +1 @@ +Module(mkdocs.commands.serve): 2664410690768 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.base.md b/examples_old/docs/api/mkdocs.config.base.md new file mode 100644 index 00000000..0341eb74 --- /dev/null +++ b/examples_old/docs/api/mkdocs.config.base.md @@ -0,0 +1 @@ +Module(mkdocs.config.base): 2664410693264 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.config_options.md b/examples_old/docs/api/mkdocs.config.config_options.md new file mode 100644 index 00000000..8480637a --- /dev/null +++ b/examples_old/docs/api/mkdocs.config.config_options.md @@ -0,0 +1 @@ +Module(mkdocs.config.config_options): 2664410692592 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.defaults.md b/examples_old/docs/api/mkdocs.config.defaults.md new file mode 100644 index 00000000..3fde904a --- /dev/null +++ b/examples_old/docs/api/mkdocs.config.defaults.md @@ -0,0 +1 @@ +Module(mkdocs.config.defaults): 2664410705472 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.md b/examples_old/docs/api/mkdocs.config.md new file mode 100644 index 00000000..d32f5bc4 --- /dev/null +++ b/examples_old/docs/api/mkdocs.config.md @@ -0,0 +1 @@ +Module(mkdocs.config): 2664410693120 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.md b/examples_old/docs/api/mkdocs.contrib.md new file mode 100644 index 00000000..d72727a5 --- /dev/null +++ b/examples_old/docs/api/mkdocs.contrib.md @@ -0,0 +1 @@ +Module(mkdocs.contrib): 2664410972480 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.search.md b/examples_old/docs/api/mkdocs.contrib.search.md new file mode 100644 index 00000000..e5d034ab --- /dev/null +++ b/examples_old/docs/api/mkdocs.contrib.search.md @@ -0,0 +1 @@ +Module(mkdocs.contrib.search): 2664415527760 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.search.search_index.md b/examples_old/docs/api/mkdocs.contrib.search.search_index.md new file mode 100644 index 00000000..dbed6a87 --- /dev/null +++ b/examples_old/docs/api/mkdocs.contrib.search.search_index.md @@ -0,0 +1 @@ +Module(mkdocs.contrib.search.search_index): 2664415680112 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.exceptions.md b/examples_old/docs/api/mkdocs.exceptions.md new file mode 100644 index 00000000..634a3e92 --- /dev/null +++ b/examples_old/docs/api/mkdocs.exceptions.md @@ -0,0 +1 @@ +Module(mkdocs.exceptions): 2664415527664 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.livereload.md b/examples_old/docs/api/mkdocs.livereload.md new file mode 100644 index 00000000..daa7d082 --- /dev/null +++ b/examples_old/docs/api/mkdocs.livereload.md @@ -0,0 +1 @@ +Module(mkdocs.livereload): 2664410692928 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.localization.md b/examples_old/docs/api/mkdocs.localization.md new file mode 100644 index 00000000..6bcda943 --- /dev/null +++ b/examples_old/docs/api/mkdocs.localization.md @@ -0,0 +1 @@ +Module(mkdocs.localization): 2664416059824 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.md b/examples_old/docs/api/mkdocs.md new file mode 100644 index 00000000..2d3a96ce --- /dev/null +++ b/examples_old/docs/api/mkdocs.md @@ -0,0 +1 @@ +Module(mkdocs): 2664410679200 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.plugins.md b/examples_old/docs/api/mkdocs.plugins.md new file mode 100644 index 00000000..fc858ca5 --- /dev/null +++ b/examples_old/docs/api/mkdocs.plugins.md @@ -0,0 +1 @@ +Module(mkdocs.plugins): 2664416061216 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.files.md b/examples_old/docs/api/mkdocs.structure.files.md new file mode 100644 index 00000000..1070d6c3 --- /dev/null +++ b/examples_old/docs/api/mkdocs.structure.files.md @@ -0,0 +1 @@ +Module(mkdocs.structure.files): 2664415686352 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.md b/examples_old/docs/api/mkdocs.structure.md new file mode 100644 index 00000000..1296d855 --- /dev/null +++ b/examples_old/docs/api/mkdocs.structure.md @@ -0,0 +1 @@ +Module(mkdocs.structure): 2664415688656 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.nav.md b/examples_old/docs/api/mkdocs.structure.nav.md new file mode 100644 index 00000000..de9cfb77 --- /dev/null +++ b/examples_old/docs/api/mkdocs.structure.nav.md @@ -0,0 +1 @@ +Module(mkdocs.structure.nav): 2664415685824 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.pages.md b/examples_old/docs/api/mkdocs.structure.pages.md new file mode 100644 index 00000000..d3b4dae0 --- /dev/null +++ b/examples_old/docs/api/mkdocs.structure.pages.md @@ -0,0 +1 @@ +Module(mkdocs.structure.pages): 2664415721040 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.toc.md b/examples_old/docs/api/mkdocs.structure.toc.md new file mode 100644 index 00000000..d5d62942 --- /dev/null +++ b/examples_old/docs/api/mkdocs.structure.toc.md @@ -0,0 +1 @@ +Module(mkdocs.structure.toc): 2664415721568 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.theme.md b/examples_old/docs/api/mkdocs.theme.md new file mode 100644 index 00000000..7e6e7a1a --- /dev/null +++ b/examples_old/docs/api/mkdocs.theme.md @@ -0,0 +1 @@ +Module(mkdocs.theme): 2664416066112 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.md b/examples_old/docs/api/mkdocs.themes.md new file mode 100644 index 00000000..12cfddf6 --- /dev/null +++ b/examples_old/docs/api/mkdocs.themes.md @@ -0,0 +1 @@ +Module(mkdocs.themes): 2664415718400 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.mkdocs.md b/examples_old/docs/api/mkdocs.themes.mkdocs.md new file mode 100644 index 00000000..4d11358f --- /dev/null +++ b/examples_old/docs/api/mkdocs.themes.mkdocs.md @@ -0,0 +1 @@ +Module(mkdocs.themes.mkdocs): 2664392576896 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.readthedocs.md b/examples_old/docs/api/mkdocs.themes.readthedocs.md new file mode 100644 index 00000000..7f78590a --- /dev/null +++ b/examples_old/docs/api/mkdocs.themes.readthedocs.md @@ -0,0 +1 @@ +Module(mkdocs.themes.readthedocs): 2664410443664 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.babel_stub.md b/examples_old/docs/api/mkdocs.utils.babel_stub.md new file mode 100644 index 00000000..aae7e057 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.babel_stub.md @@ -0,0 +1 @@ +Module(mkdocs.utils.babel_stub): 2664416045360 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.cache.md b/examples_old/docs/api/mkdocs.utils.cache.md new file mode 100644 index 00000000..f76cb540 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.cache.md @@ -0,0 +1 @@ +Module(mkdocs.utils.cache): 2664416045600 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.filters.md b/examples_old/docs/api/mkdocs.utils.filters.md new file mode 100644 index 00000000..c943df41 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.filters.md @@ -0,0 +1 @@ +Module(mkdocs.utils.filters): 2664386684752 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.md b/examples_old/docs/api/mkdocs.utils.md new file mode 100644 index 00000000..8a45c3cd --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.md @@ -0,0 +1 @@ +Module(mkdocs.utils): 2664415686064 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.meta.md b/examples_old/docs/api/mkdocs.utils.meta.md new file mode 100644 index 00000000..71cb06f8 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.meta.md @@ -0,0 +1 @@ +Module(mkdocs.utils.meta): 2664416047856 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.templates.md b/examples_old/docs/api/mkdocs.utils.templates.md new file mode 100644 index 00000000..e4fd6034 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.templates.md @@ -0,0 +1 @@ +Module(mkdocs.utils.templates): 2664416049008 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.yaml.md b/examples_old/docs/api/mkdocs.utils.yaml.md new file mode 100644 index 00000000..41e239d2 --- /dev/null +++ b/examples_old/docs/api/mkdocs.utils.yaml.md @@ -0,0 +1 @@ +Module(mkdocs.utils.yaml): 2664416048960 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.config.md b/examples_old/docs/api2/mkapi.config.md new file mode 100644 index 00000000..2cc69daa --- /dev/null +++ b/examples_old/docs/api2/mkapi.config.md @@ -0,0 +1 @@ +Module(mkapi.config): 2664410437280 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.converter.md b/examples_old/docs/api2/mkapi.converter.md new file mode 100644 index 00000000..47eebd9b --- /dev/null +++ b/examples_old/docs/api2/mkapi.converter.md @@ -0,0 +1 @@ +Module(mkapi.converter): 2664356502016 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.docstrings.md b/examples_old/docs/api2/mkapi.docstrings.md new file mode 100644 index 00000000..c9524d04 --- /dev/null +++ b/examples_old/docs/api2/mkapi.docstrings.md @@ -0,0 +1 @@ +Module(mkapi.docstrings): 2664410445056 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.filter.md b/examples_old/docs/api2/mkapi.filter.md new file mode 100644 index 00000000..f92c8132 --- /dev/null +++ b/examples_old/docs/api2/mkapi.filter.md @@ -0,0 +1 @@ +Module(mkapi.filter): 2664410518720 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.inspect.md b/examples_old/docs/api2/mkapi.inspect.md new file mode 100644 index 00000000..b511baab --- /dev/null +++ b/examples_old/docs/api2/mkapi.inspect.md @@ -0,0 +1 @@ +Module(mkapi.inspect): 2664391966688 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.link.md b/examples_old/docs/api2/mkapi.link.md new file mode 100644 index 00000000..97540cd1 --- /dev/null +++ b/examples_old/docs/api2/mkapi.link.md @@ -0,0 +1 @@ +Module(mkapi.link): 2664400667520 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.md b/examples_old/docs/api2/mkapi.md new file mode 100644 index 00000000..db3c266c --- /dev/null +++ b/examples_old/docs/api2/mkapi.md @@ -0,0 +1 @@ +Module(mkapi): 2664410434352 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.nodes.md b/examples_old/docs/api2/mkapi.nodes.md new file mode 100644 index 00000000..f28c22f6 --- /dev/null +++ b/examples_old/docs/api2/mkapi.nodes.md @@ -0,0 +1 @@ +Module(mkapi.nodes): 2664410520640 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.objects.md b/examples_old/docs/api2/mkapi.objects.md new file mode 100644 index 00000000..c2704ca4 --- /dev/null +++ b/examples_old/docs/api2/mkapi.objects.md @@ -0,0 +1 @@ +Module(mkapi.objects): 2664400669872 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.pages.md b/examples_old/docs/api2/mkapi.pages.md new file mode 100644 index 00000000..01335a5e --- /dev/null +++ b/examples_old/docs/api2/mkapi.pages.md @@ -0,0 +1 @@ +Module(mkapi.pages): 2664410526928 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.plugins.md b/examples_old/docs/api2/mkapi.plugins.md new file mode 100644 index 00000000..82c47819 --- /dev/null +++ b/examples_old/docs/api2/mkapi.plugins.md @@ -0,0 +1 @@ +Module(mkapi.plugins): 2664410661760 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.renderer.md b/examples_old/docs/api2/mkapi.renderer.md new file mode 100644 index 00000000..1210e87b --- /dev/null +++ b/examples_old/docs/api2/mkapi.renderer.md @@ -0,0 +1 @@ +Module(mkapi.renderer): 2664410670304 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.utils.md b/examples_old/docs/api2/mkapi.utils.md new file mode 100644 index 00000000..0028e533 --- /dev/null +++ b/examples_old/docs/api2/mkapi.utils.md @@ -0,0 +1 @@ +Module(mkapi.utils): 2664410672464 \ No newline at end of file diff --git a/examples_old/docs/custom.css b/examples_old/docs/custom.css new file mode 100644 index 00000000..543a6c4a --- /dev/null +++ b/examples_old/docs/custom.css @@ -0,0 +1,3 @@ +h1, h2, h3 { + margin-bottom: 10px; +} diff --git a/examples_old/docs/index.md b/examples_old/docs/index.md new file mode 100644 index 00000000..cefbeac2 --- /dev/null +++ b/examples_old/docs/index.md @@ -0,0 +1,13 @@ +# Home + +[Object][mkapi.objects] + +[ABC][bdc] + +## ![mkapi](mkapi.objects) + +## ![mkapi](mkapi.objects.Object) + +## ![mkapi](mkapi.objects.Module) + +### [Object](mkapi.objects.Object) diff --git a/examples/inherit.py b/examples_old/inherit.py similarity index 100% rename from examples/inherit.py rename to examples_old/inherit.py diff --git a/examples/inherit_comment.py b/examples_old/inherit_comment.py similarity index 100% rename from examples/inherit_comment.py rename to examples_old/inherit_comment.py diff --git a/examples/inspect.py b/examples_old/inspect.py similarity index 100% rename from examples/inspect.py rename to examples_old/inspect.py diff --git a/examples/link/__init__.py b/examples_old/link/__init__.py similarity index 100% rename from examples/link/__init__.py rename to examples_old/link/__init__.py diff --git a/examples/link/fullname.py b/examples_old/link/fullname.py similarity index 100% rename from examples/link/fullname.py rename to examples_old/link/fullname.py diff --git a/examples/link/qualname.py b/examples_old/link/qualname.py similarity index 100% rename from examples/link/qualname.py rename to examples_old/link/qualname.py diff --git a/examples/meta.py b/examples_old/meta.py similarity index 100% rename from examples/meta.py rename to examples_old/meta.py diff --git a/examples_old/mkdocs.yml b/examples_old/mkdocs.yml new file mode 100644 index 00000000..7e8b14e6 --- /dev/null +++ b/examples_old/mkdocs.yml @@ -0,0 +1,36 @@ +site_name: Doc for CI +site_url: https://daizutabi.github.io/mkapi/ +site_description: API documentation with MkDocs. +site_author: daizutabi +repo_url: https://github.com/daizutabi/mkapi/ +edit_uri: "" +theme: + name: mkdocs + highlightjs: true + hljs_languages: + - yaml +plugins: + - mkapi: + src_dirs: [.] + on_config: custom.on_config + filters: [plugin_filter] + exclude: [.tests] + page_title: custom.page_title + section_title: custom.section_title + +nav: + - index.md + - /mkapi.objects|nav_filter1|nav_filter2 + - Section: + - 1.md + - /mkapi|nav_filter3 + - 2.md + - API: /mkdocs|nav_filter4 + + +extra_css: + - custom.css +extra_javascript: + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js +markdown_extensions: + - pymdownx.arithmatex \ No newline at end of file diff --git a/examples_old/styles/__init__.py b/examples_old/styles/__init__.py new file mode 100644 index 00000000..5e75222f --- /dev/null +++ b/examples_old/styles/__init__.py @@ -0,0 +1,4 @@ +"""" +http://ja.dochub.org/sphinx/usage/extensions/example_google.html#example-google +http://ja.dochub.org/sphinx/usage/extensions/example_numpy.html#example-numpy +""" diff --git a/examples_old/styles/example_google.py b/examples_old/styles/example_google.py new file mode 100644 index 00000000..5fde6e22 --- /dev/null +++ b/examples_old/styles/example_google.py @@ -0,0 +1,314 @@ +"""Example Google style docstrings. + +This module demonstrates documentation as specified by the `Google Python +Style Guide`_. Docstrings may extend over multiple lines. Sections are created +with a section header and a colon followed by a block of indented text. + +Example: + Examples can be given using either the ``Example`` or ``Examples`` + sections. Sections support any reStructuredText formatting, including + literal blocks:: + + $ python example_google.py + +Section breaks are created by resuming unindented text. Section breaks +are also implicitly created anytime a new section starts. + +Attributes: + module_level_variable1 (int): Module level variables may be documented in + either the ``Attributes`` section of the module docstring, or in an + inline docstring immediately following the variable. + + Either form is acceptable, but the two should not be mixed. Choose + one convention to document module level variables and be consistent + with it. + +Todo: + * For module TODOs + * You have to also use ``sphinx.ext.todo`` extension + +.. _Google Python Style Guide: + https://google.github.io/styleguide/pyguide.html + +""" + +module_level_variable1 = 12345 + +module_level_variable2 = 98765 +"""int: Module level variable documented inline. + +The docstring may span multiple lines. The type may optionally be specified +on the first line, separated by a colon. +""" + + +def function_with_types_in_docstring(param1, param2): + """Example function with types documented in the docstring. + + `PEP 484`_ type annotations are supported. If attribute, parameter, and + return types are annotated according to `PEP 484`_, they do not need to be + included in the docstring: + + Args: + param1 (int): The first parameter. + param2 (str): The second parameter. + + Returns: + bool: The return value. True for success, False otherwise. + + .. _PEP 484: + https://www.python.org/dev/peps/pep-0484/ + + """ + + +def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: + """Example function with PEP 484 type annotations. + + Args: + param1: The first parameter. + param2: The second parameter. + + Returns: + The return value. True for success, False otherwise. + + """ + + +def module_level_function(param1, param2=None, *args, **kwargs): + """This is an example of a module level function. + + Function parameters should be documented in the ``Args`` section. The name + of each parameter is required. The type and description of each parameter + is optional, but should be included if not obvious. + + If ``*args`` or ``**kwargs`` are accepted, + they should be listed as ``*args`` and ``**kwargs``. + + The format for a parameter is:: + + name (type): description + The description may span multiple lines. Following + lines should be indented. The "(type)" is optional. + + Multiple paragraphs are supported in parameter + descriptions. + + Args: + param1 (int): The first parameter. + param2 (:obj:`str`, optional): The second parameter. Defaults to None. + Second line of description should be indented. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + bool: True if successful, False otherwise. + + The return type is optional and may be specified at the beginning of + the ``Returns`` section followed by a colon. + + The ``Returns`` section may span multiple lines and paragraphs. + Following lines should be indented to match the first line. + + The ``Returns`` section supports any reStructuredText formatting, + including literal blocks:: + + { + 'param1': param1, + 'param2': param2 + } + + Raises: + AttributeError: The ``Raises`` section is a list of all exceptions + that are relevant to the interface. + ValueError: If `param2` is equal to `param1`. + + """ + if param1 == param2: + raise ValueError('param1 may not be equal to param2') + return True + + +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Args: + n (int): The upper limit of the range to generate, from 0 to `n` - 1. + + Yields: + int: The next number in the range of 0 to `n` - 1. + + Examples: + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + for i in range(n): + yield i + + +class ExampleError(Exception): + """Exceptions are documented in the same way as classes. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + msg (str): Human readable string describing the exception. + code (:obj:`int`, optional): Error code. + + Attributes: + msg (str): Human readable string describing the exception. + code (int): Exception error code. + + """ + + def __init__(self, msg, code): + self.msg = msg + self.code = code + + +class ExampleClass: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. Alternatively, attributes may be documented + inline with the attribute's declaration (see __init__ method below). + + Properties created with the ``@property`` decorator should be documented + in the property's getter method. + + Attributes: + attr1 (str): Description of `attr1`. + attr2 (:obj:`int`, optional): Description of `attr2`. + + """ + + def __init__(self, param1, param2, param3): + """Example of docstring on the __init__ method. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + param1 (str): Description of `param1`. + param2 (:obj:`int`, optional): Description of `param2`. Multiple + lines are supported. + param3 (list(str)): Description of `param3`. + + """ + self.attr1 = param1 + self.attr2 = param2 + self.attr3 = param3 #: Doc comment *inline* with attribute + + #: list(str): Doc comment *before* attribute, with type specified + self.attr4 = ['attr4'] + + self.attr5 = None + """str: Docstring *after* attribute, with type specified.""" + + @property + def readonly_property(self): + """str: Properties should be documented in their getter method.""" + return 'readonly_property' + + @property + def readwrite_property(self): + """list(str): Properties with both a getter and setter + should only be documented in their getter method. + + If the setter method contains notable behavior, it should be + mentioned here. + """ + return ['readwrite_property'] + + @readwrite_property.setter + def readwrite_property(self, value): + value + + def example_method(self, param1, param2): + """Class methods are similar to regular functions. + + Note: + Do not include the `self` parameter in the ``Args`` section. + + Args: + param1: The first parameter. + param2: The second parameter. + + Returns: + True if successful, False otherwise. + + """ + return True + + def __special__(self): + """By default special members with docstrings are not included. + + Special members are any methods or attributes that start with and + end with a double underscore. Any special member with a docstring + will be included in the output, if + ``napoleon_include_special_with_doc`` is set to True. + + This behavior can be enabled by changing the following setting in + Sphinx's conf.py:: + + napoleon_include_special_with_doc = True + + """ + pass + + def __special_without_docstring__(self): + pass + + def _private(self): + """By default private members are not included. + + Private members are any methods or attributes that start with an + underscore and are *not* special. By default they are not included + in the output. + + This behavior can be changed such that private members *are* included + by changing the following setting in Sphinx's conf.py:: + + napoleon_include_private_with_doc = True + + """ + pass + + def _private_without_docstring(self): + pass + +class ExamplePEP526Class: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. If ``napoleon_attr_annotations`` + is True, types can be specified in the class body using ``PEP 526`` + annotations. + + Attributes: + attr1: Description of `attr1`. + attr2: Description of `attr2`. + + """ + + attr1: str + attr2: int \ No newline at end of file diff --git a/examples_old/styles/example_numpy.py b/examples_old/styles/example_numpy.py new file mode 100644 index 00000000..2712447f --- /dev/null +++ b/examples_old/styles/example_numpy.py @@ -0,0 +1,355 @@ +"""Example NumPy style docstrings. + +This module demonstrates documentation as specified by the `NumPy +Documentation HOWTO`_. Docstrings may extend over multiple lines. Sections +are created with a section header followed by an underline of equal length. + +Example +------- +Examples can be given using either the ``Example`` or ``Examples`` +sections. Sections support any reStructuredText formatting, including +literal blocks:: + + $ python example_numpy.py + + +Section breaks are created with two blank lines. Section breaks are also +implicitly created anytime a new section starts. Section bodies *may* be +indented: + +Notes +----- + This is an example of an indented section. It's like any other section, + but the body is indented to help it stand out from surrounding text. + +If a section is indented, then a section break is created by +resuming unindented text. + +Attributes +---------- +module_level_variable1 : int + Module level variables may be documented in either the ``Attributes`` + section of the module docstring, or in an inline docstring immediately + following the variable. + + Either form is acceptable, but the two should not be mixed. Choose + one convention to document module level variables and be consistent + with it. + + +.. _NumPy Documentation HOWTO: + https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + +module_level_variable1 = 12345 + +module_level_variable2 = 98765 +"""int: Module level variable documented inline. + +The docstring may span multiple lines. The type may optionally be specified +on the first line, separated by a colon. +""" + + +def function_with_types_in_docstring(param1, param2): + """Example function with types documented in the docstring. + + `PEP 484`_ type annotations are supported. If attribute, parameter, and + return types are annotated according to `PEP 484`_, they do not need to be + included in the docstring: + + Parameters + ---------- + param1 : int + The first parameter. + param2 : str + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + .. _PEP 484: + https://www.python.org/dev/peps/pep-0484/ + + """ + + +def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: + """Example function with PEP 484 type annotations. + + The return type must be duplicated in the docstring to comply + with the NumPy docstring style. + + Parameters + ---------- + param1 + The first parameter. + param2 + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + """ + + +def module_level_function(param1, param2=None, *args, **kwargs): + """This is an example of a module level function. + + Function parameters should be documented in the ``Parameters`` section. + The name of each parameter is required. The type and description of each + parameter is optional, but should be included if not obvious. + + If ``*args`` or ``**kwargs`` are accepted, + they should be listed as ``*args`` and ``**kwargs``. + + The format for a parameter is:: + + name : type + description + + The description may span multiple lines. Following lines + should be indented to match the first line of the description. + The ": type" is optional. + + Multiple paragraphs are supported in parameter + descriptions. + + Parameters + ---------- + param1 : int + The first parameter. + param2 : :obj:`str`, optional + The second parameter. + *args + Variable length argument list. + **kwargs + Arbitrary keyword arguments. + + Returns + ------- + bool + True if successful, False otherwise. + + The return type is not optional. The ``Returns`` section may span + multiple lines and paragraphs. Following lines should be indented to + match the first line of the description. + + The ``Returns`` section supports any reStructuredText formatting, + including literal blocks:: + + { + 'param1': param1, + 'param2': param2 + } + + Raises + ------ + AttributeError + The ``Raises`` section is a list of all exceptions + that are relevant to the interface. + ValueError + If `param2` is equal to `param1`. + + """ + if param1 == param2: + raise ValueError('param1 may not be equal to param2') + return True + + +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Parameters + ---------- + n : int + The upper limit of the range to generate, from 0 to `n` - 1. + + Yields + ------ + int + The next number in the range of 0 to `n` - 1. + + Examples + -------- + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + for i in range(n): + yield i + + +class ExampleError(Exception): + """Exceptions are documented in the same way as classes. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + msg : str + Human readable string describing the exception. + code : :obj:`int`, optional + Numeric error code. + + Attributes + ---------- + msg : str + Human readable string describing the exception. + code : int + Numeric error code. + + """ + + def __init__(self, msg, code): + self.msg = msg + self.code = code + + +class ExampleClass: + """The summary line for a class docstring should fit on one line. + + If the class has public attributes, they may be documented here + in an ``Attributes`` section and follow the same formatting as a + function's ``Args`` section. Alternatively, attributes may be documented + inline with the attribute's declaration (see __init__ method below). + + Properties created with the ``@property`` decorator should be documented + in the property's getter method. + + Attributes + ---------- + attr1 : str + Description of `attr1`. + attr2 : :obj:`int`, optional + Description of `attr2`. + + """ + + def __init__(self, param1, param2, param3): + """Example of docstring on the __init__ method. + + The __init__ method may be documented in either the class level + docstring, or as a docstring on the __init__ method itself. + + Either form is acceptable, but the two should not be mixed. Choose one + convention to document the __init__ method and be consistent with it. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + param1 : str + Description of `param1`. + param2 : list(str) + Description of `param2`. Multiple + lines are supported. + param3 : :obj:`int`, optional + Description of `param3`. + + """ + self.attr1 = param1 + self.attr2 = param2 + self.attr3 = param3 #: Doc comment *inline* with attribute + + #: list(str): Doc comment *before* attribute, with type specified + self.attr4 = ["attr4"] + + self.attr5 = None + """str: Docstring *after* attribute, with type specified.""" + + @property + def readonly_property(self): + """str: Properties should be documented in their getter method.""" + return "readonly_property" + + @property + def readwrite_property(self): + """list(str): Properties with both a getter and setter + should only be documented in their getter method. + + If the setter method contains notable behavior, it should be + mentioned here. + """ + return ["readwrite_property"] + + @readwrite_property.setter + def readwrite_property(self, value): + value + + def example_method(self, param1, param2): + """Class methods are similar to regular functions. + + Note + ---- + Do not include the `self` parameter in the ``Parameters`` section. + + Parameters + ---------- + param1 + The first parameter. + param2 + The second parameter. + + Returns + ------- + bool + True if successful, False otherwise. + + """ + return True + + def __special__(self): + """By default special members with docstrings are not included. + + Special members are any methods or attributes that start with and + end with a double underscore. Any special member with a docstring + will be included in the output, if + ``napoleon_include_special_with_doc`` is set to True. + + This behavior can be enabled by changing the following setting in + Sphinx's conf.py:: + + napoleon_include_special_with_doc = True + + """ + pass + + def __special_without_docstring__(self): + pass + + def _private(self): + """By default private members are not included. + + Private members are any methods or attributes that start with an + underscore and are *not* special. By default they are not included + in the output. + + This behavior can be changed such that private members *are* included + by changing the following setting in Sphinx's conf.py:: + + napoleon_include_private_with_doc = True + + """ + pass + + def _private_without_docstring(self): + pass diff --git a/tests/inspect/test_field.py b/src/mkapi/inspect.py similarity index 100% rename from tests/inspect/test_field.py rename to src/mkapi/inspect.py diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 69798859..19acde54 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -2,11 +2,13 @@ from __future__ import annotations import ast +import importlib import importlib.util +import inspect import re from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import mkapi.ast from mkapi import docstrings @@ -14,7 +16,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from inspect import _ParameterKind + from inspect import _IntrospectableCallable, _ParameterKind from mkapi.docstrings import Docstring, Item, Section, Style @@ -405,6 +407,7 @@ def get_module(name: str) -> Module | None: module.name = name module.source = source module.kind = "package" if path.stem == "__init__" else "module" + _postprocess_module(module) _postprocess(module) return module @@ -588,17 +591,16 @@ def _postprocess(obj: Module | Class) -> None: _set_parent(obj) _register_object(obj) _merge_docstring(obj) - if isinstance(obj, Class): - _move_property(obj) - for cls in obj.classes: - _postprocess(cls) - _postprocess_class(cls) for attr in obj.attributes: _register_object(attr) _merge_attribute_docstring(attr) for func in obj.functions: _register_object(func) _merge_docstring(func) + for cls in obj.classes: + _move_property(cls) + _postprocess(cls) + _postprocess_class(cls) ATTRIBUTE_ORDER_DICT = { @@ -624,3 +626,43 @@ def _postprocess_class(cls: Class) -> None: cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") # TODO: dataclass, bases + + +def _stringize_signature(signature: inspect.Signature) -> str: + print(signature) + return str(signature).replace("'", "") + + +def _get_function_from_object(obj: _IntrospectableCallable) -> Function | None: + print("AA", obj) + try: + signature = inspect.signature(obj) + except: + return None + sig_str = _stringize_signature(signature) + source = f"def f{sig_str}:\n pass" + node = ast.parse(source) + func = next(iter_callables(node)) + if not isinstance(func, Function): + return None + return func + + +def _set_parameters_from_object(obj: Module | Class, members: dict[str, Any]) -> None: + for cls in obj.classes: + print("BB", cls) + cls_obj = members[cls.name] + if f := _get_function_from_object(cls_obj): + cls.parameters = f.parameters + # _set_parameters_from_object(cls, dict(inspect.getmembers(cls_obj))) + # if isinstance(obj, Class): + # for func in obj.functions: + # f = _get_function_from_object(members[func.name]) + # func.parameters = f.parameters + + +def _postprocess_module(obj: Module) -> None: + return + module_obj = importlib.import_module(obj.name) + members = dict(inspect.getmembers(module_obj)) + _set_parameters_from_object(obj, members) diff --git a/tests/objects/test_base.py b/tests/objects/test_base.py deleted file mode 100644 index 02457100..00000000 --- a/tests/objects/test_base.py +++ /dev/null @@ -1,15 +0,0 @@ -import ast - -from mkapi.objects import CACHE_OBJECT, Class, get_object - - -def test_baseclass(): - pass - # # cls = get_object("mkapi.plugins.MkAPIConfig") - # # assert isinstance(cls, Class) - # # base = cls._node.bases[0] - # # assert isinstance(base, ast.Name) - # # print(ast.unparse(base)) - - # print(get_object("mkapi.plugins.MkdocsConfig")) - # print(list(CACHE_OBJECT.keys())) diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py new file mode 100644 index 00000000..92931426 --- /dev/null +++ b/tests/objects/test_class.py @@ -0,0 +1,12 @@ +from mkapi.objects import _postprocess_module, get_module + + +def test_signature(): + module = get_module("mkapi.objects") + assert module + _postprocess_module(module) + cls = module.get_class("Class") + assert cls + for p in cls.parameters: + print(p, p.type) + # assert 0 diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py index cdafe0ac..cfa12f37 100644 --- a/tests/objects/test_import.py +++ b/tests/objects/test_import.py @@ -9,23 +9,24 @@ ) -def test_import(): - module = get_module("mkapi.plugins") - assert module - set_import_object(module) +# def test_import(): +# module = get_module("mkapi.plugins") +# assert module +# set_import_object(module) - i = module.get("annotations") - assert isinstance(i, Import) - assert isinstance(i.object, Attribute) - i = module.get("importlib") - assert isinstance(i, Import) - assert isinstance(i.object, Module) - i = module.get("Path") - assert isinstance(i, Import) - assert isinstance(i.object, Class) - i = module.get("get_files") - assert isinstance(i, Import) - assert isinstance(i.object, Function) +# i = module.get("annotations") +# assert isinstance(i, Import) +# assert isinstance(i.object, Attribute) +# i = module.get("importlib") +# assert isinstance(i, Import) +# assert isinstance(i.object, Module) +# i = module.get("Path") +# assert isinstance(i, Import) +# assert isinstance(i.object, Class) +# i = module.get("get_files") +# assert isinstance(i, Import) +# assert isinstance(i.object, Function) - # for x in module.imports: - # print(x.name, x.fullname, x.object) + +# for x in module.imports: +# print(x.name, x.fullname, x.object) diff --git a/tests/test_dataclass.py b/tests/test_dataclass.py index ed3f6aff..b3702376 100644 --- a/tests/test_dataclass.py +++ b/tests/test_dataclass.py @@ -1,6 +1,11 @@ -import ast +import importlib +import inspect +import sys +import tempfile +from pathlib import Path + +import pytest -import mkapi.ast from mkapi.dataclasses import ( _get_dataclass_decorator, _iter_decorator_args, @@ -40,3 +45,29 @@ def test_decorator_arg(): deco_dict = dict(_iter_decorator_args(deco)) assert deco_dict["init"] assert not deco_dict["repr"] + + +@pytest.fixture() +def path(): + path = Path(tempfile.NamedTemporaryFile(suffix=".py", delete=False).name) + sys.path.insert(0, str(path.parent)) + yield path + del sys.path[0] + path.unlink() + + +@pytest.fixture() +def load(path: Path): + def load(source: str): + with path.open("w") as f: + f.write(source) + module = importlib.import_module(path.stem) + cls = dict(inspect.getmembers(module))["C"] + params = inspect.signature(cls).parameters + module = get_module(path.stem) + assert module + cls = module.get_class("C") + assert cls + return cls.parameters, params + + return load From ace591e624c7687f8c23afb60b777d901099b89d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Tue, 9 Jan 2024 21:58:31 +0900 Subject: [PATCH 070/148] base classes --- src/mkapi/ast.py | 22 ++++++ src/mkapi/dataclasses.py | 2 +- src/mkapi/docstrings.py | 19 +++++- src/mkapi/inspect.py | 31 +++++++++ src/mkapi/objects.py | 132 +++++++++++++++--------------------- tests/objects/test_base.py | 0 tests/objects/test_class.py | 43 ++++++++---- tests/objects/test_iter.py | 7 -- 8 files changed, 159 insertions(+), 97 deletions(-) create mode 100644 tests/objects/test_base.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 4ee4acac..dd912a7b 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast +import re from ast import ( AnnAssign, Assign, @@ -133,6 +134,27 @@ def iter_callable_nodes( yield child +# a1.b_2(c[d]) -> a1, b_2, c, d +SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") + + +def _split_name(name: str) -> list[str]: + return [x for x in re.split(SPLIT_IDENTIFIER_PATTERN, name) if x] + + +def _is_identifier(name: str) -> bool: + return name != "" and all(x.isidentifier() for x in _split_name(name)) + + +def get_expr(name: str) -> ast.expr: + """Return an [ast.expr] instance of a name.""" + if _is_identifier(name): + expr = ast.parse(name).body[0] + if isinstance(expr, ast.Expr): + return expr.value + return ast.Constant(value=name) + + class Transformer(NodeTransformer): # noqa: D101 def _rename(self, name: str) -> Name: return Name(id=f"__mkapi__.{name}") diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index a968d0f5..3b96f2e3 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -31,4 +31,4 @@ def _iter_decorator_args(deco: ast.expr) -> Iterator[tuple[str, Any]]: def _get_decorator_args(deco: ast.expr) -> dict[str, Any]: - return dict(_get_decorator_args(deco)) + return dict(_iter_decorator_args(deco)) diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index a89c8cfd..b95c9811 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -139,6 +139,22 @@ def _subsplit(doc: str, style: Style) -> list[str]: ("Notes",), ] +CURRENT_DOCSTRING_STYLE: list[Style] = ["google"] + + +def get_style(doc: str) -> Style: + """Return the docstring style of doc. + + If the style can't be determined, the current style returned. + """ + for names in SECTION_NAMES: + for name in names: + if f"\n\n{name}\n----" in doc: + CURRENT_DOCSTRING_STYLE[0] = "numpy" + return "numpy" + CURRENT_DOCSTRING_STYLE[0] = "google" + return "google" + def _rename_section(section_name: str) -> str: for section_names in SECTION_NAMES: @@ -221,8 +237,9 @@ def get(self, name: str) -> Section | None: # noqa: D102 return get_by_name(self.sections, name) -def parse(doc: str, style: Style) -> Docstring: +def parse(doc: str, style: Style | None = None) -> Docstring: """Return a [Docstring] instance.""" + style = style or get_style(doc) doc = add_fence(doc) sections = list(iter_sections(doc, style)) return Docstring("", "", "", sections) diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py index e69de29b..38523fc9 100644 --- a/src/mkapi/inspect.py +++ b/src/mkapi/inspect.py @@ -0,0 +1,31 @@ +"""Inspect module.""" +from __future__ import annotations + +import ast +import inspect +from ast import FunctionDef +from inspect import Parameter, Signature +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from inspect import _IntrospectableCallable + + +def _stringize_signature(signature: Signature) -> str: + ps = [] + for p in signature.parameters.values(): + ps.append(p.replace(default=inspect.Parameter.empty)) # noqa: PERF401 + sig_str = str(signature.replace(parameters=ps)).replace("'", "") + print(sig_str) + return sig_str + + +def get_node_from_callable(obj: _IntrospectableCallable) -> FunctionDef: + signature = inspect.signature(obj) + sig_str = _stringize_signature(signature) + source = f"def f{sig_str}:\n pass" + node = ast.parse(source).body[0] + if not isinstance(node, FunctionDef): + raise NotImplementedError + return node diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 19acde54..4370c97c 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,20 +5,20 @@ import importlib import importlib.util import inspect -import re from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any import mkapi.ast from mkapi import docstrings +from mkapi.inspect import get_node_from_callable from mkapi.utils import del_by_name, get_by_name, unique_names if TYPE_CHECKING: from collections.abc import Iterator from inspect import _IntrospectableCallable, _ParameterKind - from mkapi.docstrings import Docstring, Item, Section, Style + from mkapi.docstrings import Docstring, Item, Section CURRENT_MODULE_NAME: list[str | None] = [None] @@ -278,17 +278,28 @@ def _get_callable_args( return parameters, raises, node.decorator_list, node.type_params +def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: + """Return a [Class] or [Function] instancd.""" + args = (node.name, None, *_get_callable_args(node), None) + return Function(node, *args, get_return(node)) + + +def get_class(node: ast.ClassDef) -> Class: + """Return a [Class] instance.""" + args = (node.name, None, *_get_callable_args(node), None) + attrs = list(iter_attributes(node)) + classes, functions = get_callables(node) + bases: list[Class] = [] + return Class(node, *args, attrs, classes, functions, bases) + + def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function]: """Yield classes or functions.""" for child in mkapi.ast.iter_callable_nodes(node): - args = (child.name, None, *_get_callable_args(child), None) if isinstance(child, ast.ClassDef): - attrs = list(iter_attributes(child)) - classes, functions = get_callables(child) - bases: list[Class] = [] - yield Class(child, *args, attrs, classes, functions, bases) + yield get_class(child) else: - yield Function(child, *args, get_return(child)) + yield get_function(child) def get_callables( @@ -436,9 +447,12 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] - for maxsplit in range(fullname.count(".") + 1): - module_name = fullname.rsplit(".", maxsplit)[0] - if get_module(module_name) and fullname in CACHE_OBJECT: + names = fullname.split(".") + for k in range(1, len(names) + 1): + module_name = ".".join(names[:k]) + if not get_module(module_name): + return None + if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] return None @@ -460,32 +474,13 @@ def _set_import_object(import_: Import) -> None: import_.object = module.get(name) -# a1.b_2(c[d]) -> a1, b_2, c, d -SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") - - -def _split_name(name: str) -> list[str]: - return [x for x in re.split(SPLIT_IDENTIFIER_PATTERN, name) if x] - - -def _is_identifier(name: str) -> bool: - return name != "" and all(x.isidentifier() for x in _split_name(name)) - - -def _to_expr(name: str) -> ast.expr: - if _is_identifier(name): - name = name.replace("(", "[").replace(")", "]") # ex. list(str) -> list[str] - expr = ast.parse(name).body[0] - if isinstance(expr, ast.Expr): - return expr.value - return ast.Constant(value=name) - - def _merge_attribute_docstring(obj: Attribute) -> None: if doc := obj.docstring: type_, desc = docstrings.split_without_name(doc, "google") if not obj.type and type_: - obj.type = _to_expr(type_) + # ex. list(str) -> list[str] + type_ = type_.replace("(", "[").replace(")", "]") + obj.type = mkapi.ast.get_expr(type_) obj.docstring = desc @@ -509,22 +504,11 @@ def _move_property(obj: Class) -> None: obj.functions = funcs -CURRENT_DOCSTRING_STYLE: list[Style] = ["google"] - - -def _get_style(doc: str) -> Style: - for names in docstrings.SECTION_NAMES: - for name in names: - if f"\n\n{name}\n----" in doc: - CURRENT_DOCSTRING_STYLE[0] = "numpy" - return "numpy" - CURRENT_DOCSTRING_STYLE[0] = "google" - return "google" - - def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: if not obj.type and item.type: - obj.type = _to_expr(item.type) + # ex. list(str) -> list[str] + type_ = item.type.replace("(", "[").replace(")", "]") + obj.type = mkapi.ast.get_expr(type_) obj.docstring = item.text # Does item.text win? @@ -558,10 +542,8 @@ def _merge_docstring(obj: Module | Class | Function) -> None: """Merge [Object] and [Docstring].""" if not (doc := obj.get_node_docstring()): return - sections: list[Section] = [] - style = _get_style(doc) - docstring = docstrings.parse(doc, style) + docstring = docstrings.parse(doc) for section in docstring: if section.name == "Attributes" and isinstance(obj, Module | Class): obj.attributes = _merge_items(Attribute, obj.attributes, section.items) @@ -578,7 +560,7 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.docstring = docstring -def _set_parent(obj: Module | Class) -> None: +def _set_parent_of_members(obj: Module | Class) -> None: for attr in obj.attributes: attr.parent = obj for cls in obj.classes: @@ -588,7 +570,7 @@ def _set_parent(obj: Module | Class) -> None: def _postprocess(obj: Module | Class) -> None: - _set_parent(obj) + _set_parent_of_members(obj) _register_object(obj) _merge_docstring(obj) for attr in obj.attributes: @@ -612,13 +594,26 @@ def _postprocess(obj: Module | Class) -> None: def _attribute_order(attr: Attribute) -> int: - node = attr._node # noqa: SLF001 - if node is None: + if not (node := attr._node): # noqa: SLF001 return 0 return ATTRIBUTE_ORDER_DICT.get(type(node), 10) +def _iter_base_classes(cls: Class) -> Iterator[Class]: + if not (module := cls.get_module()): + return + for node in cls._node.bases: + base_name = next(mkapi.ast.iter_identifiers(node)) + base_fullname = module.get_fullname(base_name) + if not base_fullname: + continue + base = get_object(base_fullname) + if base and isinstance(base, Class): + yield base + + def _postprocess_class(cls: Class) -> None: + cls.bases = list(_iter_base_classes(cls)) if init := cls.get_function("__init__"): cls.parameters = init.parameters cls.raises = init.raises @@ -628,32 +623,17 @@ def _postprocess_class(cls: Class) -> None: # TODO: dataclass, bases -def _stringize_signature(signature: inspect.Signature) -> str: - print(signature) - return str(signature).replace("'", "") - - -def _get_function_from_object(obj: _IntrospectableCallable) -> Function | None: - print("AA", obj) - try: - signature = inspect.signature(obj) - except: - return None - sig_str = _stringize_signature(signature) - source = f"def f{sig_str}:\n pass" - node = ast.parse(source) - func = next(iter_callables(node)) - if not isinstance(func, Function): - return None - return func +def _get_function_from_callable(obj: _IntrospectableCallable) -> Function: + node = get_node_from_callable(obj) + return get_function(node) def _set_parameters_from_object(obj: Module | Class, members: dict[str, Any]) -> None: for cls in obj.classes: - print("BB", cls) cls_obj = members[cls.name] - if f := _get_function_from_object(cls_obj): - cls.parameters = f.parameters + if callable(cls_obj): + func = _get_function_from_callable(cls_obj) + cls.parameters = func.parameters # _set_parameters_from_object(cls, dict(inspect.getmembers(cls_obj))) # if isinstance(obj, Class): # for func in obj.functions: @@ -661,7 +641,7 @@ def _set_parameters_from_object(obj: Module | Class, members: dict[str, Any]) -> # func.parameters = f.parameters -def _postprocess_module(obj: Module) -> None: +def _postprocess_module(module: Module) -> None: return module_obj = importlib.import_module(obj.name) members = dict(inspect.getmembers(module_obj)) diff --git a/tests/objects/test_base.py b/tests/objects/test_base.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py index 92931426..8d5a73e5 100644 --- a/tests/objects/test_class.py +++ b/tests/objects/test_class.py @@ -1,12 +1,31 @@ -from mkapi.objects import _postprocess_module, get_module - - -def test_signature(): - module = get_module("mkapi.objects") - assert module - _postprocess_module(module) - cls = module.get_class("Class") - assert cls - for p in cls.parameters: - print(p, p.type) - # assert 0 +from mkapi.objects import ( + Class, + _iter_base_classes, + _postprocess_module, + get_module, + get_object, +) + + +def test_baseclasses(): + cls = get_object("mkapi.plugins.MkAPIPlugin") + assert isinstance(cls, Class) + base = next(_iter_base_classes(cls)) + assert base.name == "BasePlugin" + assert base.get_fullname() == "mkdocs.plugins.BasePlugin" + cls = get_object("mkapi.plugins.MkAPIConfig") + assert isinstance(cls, Class) + base = next(_iter_base_classes(cls)) + assert base.name == "Config" + assert base.get_fullname() == "mkdocs.config.base.Config" + + +# def test_signature(): +# module = get_module("mkapi.objects") +# assert module +# _postprocess_module(module) +# cls = module.get_class("Class") +# assert cls +# for p in cls.parameters: +# print(p, p.type) +# assert 0 diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index c495262b..99fb383c 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -14,18 +14,11 @@ def module(): def test_iter(module: Module): names = [o.name for o in module] - assert "Style" in names assert "CACHE_MODULE" in names assert "Class" in names assert "get_object" in names -def test_empty(module: Module): - obj = module.get("Style") - assert obj - assert list(obj) == [] - - def test_func(module: Module): func = module.get("_get_callable_args") assert func From 1c643034406eeed7cf48e636d98028a4188e3a0b Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 00:19:15 +0900 Subject: [PATCH 071/148] dataclass parameters --- src/mkapi/dataclasses.py | 38 +++++++++- src/mkapi/inspect.py | 31 -------- src/mkapi/objects.py | 38 ++++++---- .../test_base.py => dataclasses/__init__.py} | 0 tests/dataclasses/test_deco.py | 39 ++++++++++ tests/dataclasses/test_params.py | 60 +++++++++++++++ tests/docstrings/conftest.py | 4 +- tests/objects/test_class.py | 19 +---- tests/objects/test_deco.py | 8 +- tests/objects/test_iter.py | 11 ++- tests/objects/test_merge.py | 2 +- tests/test_dataclass.py | 73 ------------------- 12 files changed, 174 insertions(+), 149 deletions(-) delete mode 100644 src/mkapi/inspect.py rename tests/{objects/test_base.py => dataclasses/__init__.py} (100%) create mode 100644 tests/dataclasses/test_deco.py create mode 100644 tests/dataclasses/test_params.py delete mode 100644 tests/test_dataclass.py diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index 3b96f2e3..8f82a012 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -2,12 +2,16 @@ from __future__ import annotations import ast -from typing import TYPE_CHECKING, Any +import importlib +import inspect +from typing import TYPE_CHECKING from mkapi.ast import iter_identifiers if TYPE_CHECKING: - from mkapi.objects import Class, Iterator, Module + from inspect import _ParameterKind + + from mkapi.objects import Any, Attribute, Class, Iterator, Module def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: @@ -18,9 +22,35 @@ def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: return None -def is_dataclass(cls: Class, module: Module) -> bool: +def is_dataclass(cls: Class, module: Module | None = None) -> bool: """Return True if the class is a dataclass.""" - return _get_dataclass_decorator(cls, module) is not None + if module := module or cls.get_module(): + return _get_dataclass_decorator(cls, module) is not None + return False + + +def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: + """Yield tuples of ([Attribute], [_ParameterKind]) for dataclass signature.""" + attrs: dict[str, Attribute] = {} + for base in cls.iter_bases(): + if not is_dataclass(base): + raise NotImplementedError + for attr in base.attributes: + attrs[attr.name] = attr # updated by subclasses. + + if not (module_name := cls.get_module_name()): + raise NotImplementedError + module = importlib.import_module(module_name) + members = dict(inspect.getmembers(module, inspect.isclass)) + obj = members[cls.name] + + for param in inspect.signature(obj).parameters.values(): + if param.name not in attrs: + raise NotImplementedError + yield attrs[param.name], param.kind + + +# -------------------------------------------------------------- def _iter_decorator_args(deco: ast.expr) -> Iterator[tuple[str, Any]]: diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py deleted file mode 100644 index 38523fc9..00000000 --- a/src/mkapi/inspect.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Inspect module.""" -from __future__ import annotations - -import ast -import inspect -from ast import FunctionDef -from inspect import Parameter, Signature -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - from inspect import _IntrospectableCallable - - -def _stringize_signature(signature: Signature) -> str: - ps = [] - for p in signature.parameters.values(): - ps.append(p.replace(default=inspect.Parameter.empty)) # noqa: PERF401 - sig_str = str(signature.replace(parameters=ps)).replace("'", "") - print(sig_str) - return sig_str - - -def get_node_from_callable(obj: _IntrospectableCallable) -> FunctionDef: - signature = inspect.signature(obj) - sig_str = _stringize_signature(signature) - source = f"def f{sig_str}:\n pass" - node = ast.parse(source).body[0] - if not isinstance(node, FunctionDef): - raise NotImplementedError - return node diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 4370c97c..041f2444 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -11,7 +11,6 @@ import mkapi.ast from mkapi import docstrings -from mkapi.inspect import get_node_from_callable from mkapi.utils import del_by_name, get_by_name, unique_names if TYPE_CHECKING: @@ -269,6 +268,12 @@ def get_function(self, name: str) -> Function | None: """Return a [Function] instance by the name.""" return get_by_name(self.functions, name) + def iter_bases(self) -> Iterator[Class]: + """Yield base classes including self.""" + for base in self.bases: + yield from base.iter_bases() + yield self + def _get_callable_args( node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, @@ -320,6 +325,7 @@ class Module(Object): """Module class.""" _node: ast.Module + name: str | None docstring: Docstring | None imports: list[Import] attributes: list[Attribute] @@ -412,7 +418,7 @@ def get_module(name: str) -> Module | None: with path.open("r", encoding="utf-8") as f: source = f.read() CURRENT_MODULE_NAME[0] = name # Set the module name in the global cache. - module = _get_module_from_source(source) + module = get_module_from_source(source) CURRENT_MODULE_NAME[0] = None # Reset the module name in the global cache. CACHE_MODULE[name] = (module, mtime) module.name = name @@ -423,17 +429,18 @@ def get_module(name: str) -> Module | None: return module -def _get_module_from_source(source: str) -> Module: +def get_module_from_source(source: str) -> Module: + """Return a [Module] instance from source string.""" node = ast.parse(source) - return _get_module_from_node(node) + return get_module_from_node(node) -def _get_module_from_node(node: ast.Module) -> Module: +def get_module_from_node(node: ast.Module) -> Module: """Return a [Module] instance from the [ast.Module] node.""" imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) - return Module(node, "", None, imports, attrs, classes, functions, None, None) + return Module(node, None, None, imports, attrs, classes, functions, None, None) CACHE_OBJECT: dict[str, Module | Class | Function | Attribute] = {} @@ -474,7 +481,7 @@ def _set_import_object(import_: Import) -> None: import_.object = module.get(name) -def _merge_attribute_docstring(obj: Attribute) -> None: +def _split_attribute_docstring(obj: Attribute) -> None: if doc := obj.docstring: type_, desc = docstrings.split_without_name(doc, "google") if not obj.type and type_: @@ -499,7 +506,7 @@ def _move_property(obj: Class) -> None: type_ = func.returns.type type_params = func.type_params attr = Attribute(node, func.name, doc, type_, None, type_params, func.parent) - _merge_attribute_docstring(attr) + _split_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs @@ -575,7 +582,7 @@ def _postprocess(obj: Module | Class) -> None: _merge_docstring(obj) for attr in obj.attributes: _register_object(attr) - _merge_attribute_docstring(attr) + _split_attribute_docstring(attr) for func in obj.functions: _register_object(func) _merge_docstring(func) @@ -600,6 +607,10 @@ def _attribute_order(attr: Attribute) -> int: def _iter_base_classes(cls: Class) -> Iterator[Class]: + """Yield base classes. + + This function is called in postprocess for setting base classes. + """ if not (module := cls.get_module()): return for node in cls._node.bases: @@ -617,15 +628,16 @@ def _postprocess_class(cls: Class) -> None: if init := cls.get_function("__init__"): cls.parameters = init.parameters cls.raises = init.raises - cls.docstring = docstrings.merge(cls.docstring, init.docstring) # type: ignore + cls.docstring = docstrings.merge(cls.docstring, init.docstring) cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") - # TODO: dataclass, bases + # TODO: dataclass def _get_function_from_callable(obj: _IntrospectableCallable) -> Function: - node = get_node_from_callable(obj) - return get_function(node) + pass + # node = mkapi.ast.get_node_from_callable(obj) + # return get_function(node) def _set_parameters_from_object(obj: Module | Class, members: dict[str, Any]) -> None: diff --git a/tests/objects/test_base.py b/tests/dataclasses/__init__.py similarity index 100% rename from tests/objects/test_base.py rename to tests/dataclasses/__init__.py diff --git a/tests/dataclasses/test_deco.py b/tests/dataclasses/test_deco.py new file mode 100644 index 00000000..b074bfed --- /dev/null +++ b/tests/dataclasses/test_deco.py @@ -0,0 +1,39 @@ +from mkapi.dataclasses import ( + _get_dataclass_decorator, + _iter_decorator_args, + is_dataclass, +) +from mkapi.objects import get_module_from_source + +source = """ +import dataclasses +from dataclasses import dataclass, field +@f() +@dataclasses.dataclass +@g +class A: + pass +@dataclass(init=True,repr=False) +class B: + x: list[A]=field(init=False) + y: int + z: str=field() +""" + + +def test_decorator_arg(): + module = get_module_from_source(source) + cls = module.get_class("A") + assert cls + assert is_dataclass(cls, module) + deco = _get_dataclass_decorator(cls, module) + assert deco + assert not list(_iter_decorator_args(deco)) + cls = module.get_class("B") + assert cls + assert is_dataclass(cls, module) + deco = _get_dataclass_decorator(cls, module) + assert deco + deco_dict = dict(_iter_decorator_args(deco)) + assert deco_dict["init"] + assert not deco_dict["repr"] diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py new file mode 100644 index 00000000..fadc8ca2 --- /dev/null +++ b/tests/dataclasses/test_params.py @@ -0,0 +1,60 @@ +import ast +import importlib +import inspect + +from mkapi.dataclasses import is_dataclass +from mkapi.objects import Attribute, Class, Parameter, get_object + + +def test_parameters(): + cls = get_object("mkapi.objects.Class") + assert isinstance(cls, Class) + assert is_dataclass(cls) + module_name = cls.get_module_name() + assert module_name + module_obj = importlib.import_module(module_name) + members = dict(inspect.getmembers(module_obj, inspect.isclass)) + assert cls.name + cls_obj = members[cls.name] + params = inspect.signature(cls_obj).parameters + bases = list(cls.iter_bases()) + attrs: dict[str, Attribute] = {} + for base in bases: + if not is_dataclass(base): + raise NotImplementedError + for attr in base.attributes: + attrs[attr.name] = attr # updated by subclasses. + ps = [] + for p in params.values(): + if p.name not in attrs: + raise NotImplementedError + attr = attrs[p.name] + args = (None, p.name, attr.docstring, attr.type, attr.default, p.kind) + parameter = Parameter(*args) + ps.append(parameter) + cls.parameters = ps + p = cls.parameters + assert p[0].name == "_node" + assert ast.unparse(p[0].type) == "ast.ClassDef" + assert p[1].name == "name" + assert ast.unparse(p[1].type) == "str" + assert p[2].name == "docstring" + assert ast.unparse(p[2].type) == "Docstring | None" + assert p[3].name == "parameters" + assert ast.unparse(p[3].type) == "list[Parameter]" + assert p[4].name == "raises" + assert ast.unparse(p[4].type) == "list[Raise]" + assert p[5].name == "decorators" + assert ast.unparse(p[5].type) == "list[ast.expr]" + assert p[6].name == "type_params" + assert ast.unparse(p[6].type) == "list[ast.type_param]" + assert p[7].name == "parent" + assert ast.unparse(p[7].type) == "Class | Module | None" + assert p[8].name == "attributes" + assert ast.unparse(p[8].type) == "list[Attribute]" + assert p[9].name == "classes" + assert ast.unparse(p[9].type) == "list[Class]" + assert p[10].name == "functions" + assert ast.unparse(p[10].type) == "list[Function]" + assert p[11].name == "bases" + assert ast.unparse(p[11].type) == "list[Class]" diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index bc729416..44e88da3 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -4,7 +4,7 @@ import pytest -from mkapi.objects import _get_module_from_node, get_module_path +from mkapi.objects import get_module_from_node, get_module_path def get_module(name): @@ -16,7 +16,7 @@ def get_module(name): with path.open("r", encoding="utf-8") as f: source = f.read() node = ast.parse(source) - return _get_module_from_node(node) + return get_module_from_node(node) @pytest.fixture(scope="module") diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py index 8d5a73e5..4422aef8 100644 --- a/tests/objects/test_class.py +++ b/tests/objects/test_class.py @@ -1,10 +1,4 @@ -from mkapi.objects import ( - Class, - _iter_base_classes, - _postprocess_module, - get_module, - get_object, -) +from mkapi.objects import Class, _iter_base_classes, get_object def test_baseclasses(): @@ -18,14 +12,3 @@ def test_baseclasses(): base = next(_iter_base_classes(cls)) assert base.name == "Config" assert base.get_fullname() == "mkdocs.config.base.Config" - - -# def test_signature(): -# module = get_module("mkapi.objects") -# assert module -# _postprocess_module(module) -# cls = module.get_class("Class") -# assert cls -# for p in cls.parameters: -# print(p, p.type) -# assert 0 diff --git a/tests/objects/test_deco.py b/tests/objects/test_deco.py index 5698c329..92690409 100644 --- a/tests/objects/test_deco.py +++ b/tests/objects/test_deco.py @@ -1,14 +1,10 @@ import ast -from mkapi.objects import Class, _get_module_from_node - - -def _get(src: str): - return _get_module_from_node(ast.parse(src)) +from mkapi.objects import Class, get_module_from_source def test_deco(): - module = _get("@f(x,a=1)\nclass A:\n pass") + module = get_module_from_source("@f(x,a=1)\nclass A:\n pass") cls = module.get("A") assert isinstance(cls, Class) deco = cls.decorators[0] diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index 99fb383c..1dd11bbe 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -28,9 +28,18 @@ def test_func(module: Module): def test_iter_exprs(module: Module): - func = module.get("_get_module_from_node") + func = module.get("get_module_from_node") assert func exprs = list(func.iter_exprs()) assert len(exprs) == 2 assert ast.unparse(exprs[0]) == "ast.Module" assert ast.unparse(exprs[1]) == "Module" + + +def test_iter_bases(module: Module): + cls = module.get_class("Class") + assert cls + bases = cls.iter_bases() + assert next(bases).name == "Object" + assert next(bases).name == "Callable" + assert next(bases).name == "Class" diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index 627dc99f..1cecf20e 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -15,7 +15,7 @@ from mkapi.utils import get_by_name -def test_merge_attribute_docstring(google): +def test_split_attribute_docstring(google): name = "module_level_variable2" node = google.get(name) assert isinstance(node, Attribute) diff --git a/tests/test_dataclass.py b/tests/test_dataclass.py deleted file mode 100644 index b3702376..00000000 --- a/tests/test_dataclass.py +++ /dev/null @@ -1,73 +0,0 @@ -import importlib -import inspect -import sys -import tempfile -from pathlib import Path - -import pytest - -from mkapi.dataclasses import ( - _get_dataclass_decorator, - _iter_decorator_args, - is_dataclass, -) -from mkapi.objects import _get_module_from_source, get_module - -source = """ -import dataclasses -from dataclasses import dataclass, field -@f() -@dataclasses.dataclass -@g -class A: - pass -@dataclass(init=True,repr=False) -class B: - x: list[A]=field(init=False) - y: int - z: str=field() -""" - - -def test_decorator_arg(): - module = _get_module_from_source(source) - cls = module.get_class("A") - assert cls - assert is_dataclass(cls, module) - deco = _get_dataclass_decorator(cls, module) - assert deco - assert not list(_iter_decorator_args(deco)) - cls = module.get_class("B") - assert cls - assert is_dataclass(cls, module) - deco = _get_dataclass_decorator(cls, module) - assert deco - deco_dict = dict(_iter_decorator_args(deco)) - assert deco_dict["init"] - assert not deco_dict["repr"] - - -@pytest.fixture() -def path(): - path = Path(tempfile.NamedTemporaryFile(suffix=".py", delete=False).name) - sys.path.insert(0, str(path.parent)) - yield path - del sys.path[0] - path.unlink() - - -@pytest.fixture() -def load(path: Path): - def load(source: str): - with path.open("w") as f: - f.write(source) - module = importlib.import_module(path.stem) - cls = dict(inspect.getmembers(module))["C"] - params = inspect.signature(cls).parameters - module = get_module(path.stem) - assert module - cls = module.get_class("C") - assert cls - return cls.parameters, params - - return load From 6d044dc789bf446cf2a2862f4d6fcb253ed8a6e6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 07:19:07 +0900 Subject: [PATCH 072/148] Object.modulename --- examples/custom.py | 4 +-- examples_old/custom.py | 4 +-- src/mkapi/dataclasses.py | 4 +-- src/mkapi/objects.py | 49 +++++++++++++++++++++----------- src/mkapi/plugins.py | 18 ++++++------ src/mkapi/utils.py | 6 ++-- src_old/mkapi/core/object.py | 4 +-- tests/dataclasses/test_params.py | 6 ++-- tests/test_plugins.py | 12 ++++---- tests/test_utils.py | 16 +++++------ 10 files changed, 70 insertions(+), 53 deletions(-) diff --git a/examples/custom.py b/examples/custom.py index 5b50f4f6..ec3f69ca 100644 --- a/examples/custom.py +++ b/examples/custom.py @@ -3,8 +3,8 @@ def on_config(config, mkapi): return config -def page_title(module_name: str, depth: int, ispackage: bool) -> str: - return ".".join(module_name.split(".")[depth:]) +def page_title(modulename: str, depth: int, ispackage: bool) -> str: + return ".".join(modulename.split(".")[depth:]) def section_title(package_name: str, depth: int) -> str: diff --git a/examples_old/custom.py b/examples_old/custom.py index 5b50f4f6..ec3f69ca 100644 --- a/examples_old/custom.py +++ b/examples_old/custom.py @@ -3,8 +3,8 @@ def on_config(config, mkapi): return config -def page_title(module_name: str, depth: int, ispackage: bool) -> str: - return ".".join(module_name.split(".")[depth:]) +def page_title(modulename: str, depth: int, ispackage: bool) -> str: + return ".".join(modulename.split(".")[depth:]) def section_title(package_name: str, depth: int) -> str: diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index 8f82a012..bd058e8c 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -38,9 +38,9 @@ def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: for attr in base.attributes: attrs[attr.name] = attr # updated by subclasses. - if not (module_name := cls.get_module_name()): + if not (modulename := cls.get_modulename()): raise NotImplementedError - module = importlib.import_module(module_name) + module = importlib.import_module(modulename) members = dict(inspect.getmembers(module, inspect.isclass)) obj = members[cls.name] diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 041f2444..f4cf3a82 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,7 +5,7 @@ import importlib import importlib.util import inspect -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -20,17 +20,32 @@ from mkapi.docstrings import Docstring, Item, Section -CURRENT_MODULE_NAME: list[str | None] = [None] +MODULENAMES: list[str | None] = [None] + + +def _push_modulename(modulename: str | None) -> None: + MODULENAMES.append(modulename) + + +def _pop_modulename() -> str | None: + return MODULENAMES.pop() + + +def _get_current_modulename() -> str | None: + return MODULENAMES[-1] @dataclass class Object: # noqa: D101 _node: ast.AST name: str + modulename: str | None = field(default_factory=_get_current_modulename, init=False) + # qualname: str | None = field(init=False) + # fullname: str | None = field(init=False) docstring: str | None - def __post_init__(self) -> None: # Set parent module name. - self.__dict__["__module_name__"] = CURRENT_MODULE_NAME[0] + # def __post_init__(self) -> None: # Set parent module name. + # self.__dict__["__modulename__"] = CURRENT_MODULE_NAME[0] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -46,14 +61,15 @@ def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: else: yield obj - def get_module_name(self) -> str | None: + def get_modulename(self) -> str | None: """Return the module name.""" - return self.__dict__["__module_name__"] + return self.modulename + # return self.__dict__["__modulename__"] def get_module(self) -> Module | None: """Return a [Module] instance.""" - if module_name := self.get_module_name(): - return get_module(module_name) + if modulename := self.get_modulename(): + return get_module(modulename) return None def get_source(self, maxline: int | None = None) -> str | None: @@ -417,22 +433,23 @@ def get_module(name: str) -> Module | None: return CACHE_MODULE[name][0] with path.open("r", encoding="utf-8") as f: source = f.read() - CURRENT_MODULE_NAME[0] = name # Set the module name in the global cache. + _push_modulename(name) module = get_module_from_source(source) - CURRENT_MODULE_NAME[0] = None # Reset the module name in the global cache. CACHE_MODULE[name] = (module, mtime) module.name = name - module.source = source module.kind = "package" if path.stem == "__init__" else "module" _postprocess_module(module) _postprocess(module) + _pop_modulename() return module def get_module_from_source(source: str) -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) - return get_module_from_node(node) + module = get_module_from_node(node) + module.source = source + return module def get_module_from_node(node: ast.Module) -> Module: @@ -456,8 +473,8 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: return CACHE_OBJECT[fullname] names = fullname.split(".") for k in range(1, len(names) + 1): - module_name = ".".join(names[:k]) - if not get_module(module_name): + modulename = ".".join(names[:k]) + if not get_module(modulename): return None if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] @@ -476,8 +493,8 @@ def _set_import_object(import_: Import) -> None: return if "." not in import_.fullname: return - module_name, name = import_.fullname.rsplit(".", maxsplit=1) - if module := get_module(module_name): + modulename, name = import_.fullname.rsplit(".", maxsplit=1) + if module := get_module(modulename): import_.object = module.get(name) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 1f204a31..fd617eb5 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -25,7 +25,7 @@ import mkapi from mkapi import converter from mkapi.filter import split_filters, update_filters -from mkapi.utils import find_submodule_names, is_package +from mkapi.utils import find_submodulenames, is_package if TYPE_CHECKING: from collections.abc import Callable @@ -200,16 +200,16 @@ def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: return re.match(API_URL_PATTERN, item) is not None -def _get_path_module_name_filters( +def _get_path_modulename_filters( item: str, filters: list[str], ) -> tuple[str, str, list[str]]: if not (m := re.match(API_URL_PATTERN, item)): raise NotImplementedError - path, module_name_filter = m.groups() - module_name, filters_ = split_filters(module_name_filter) + path, modulename_filter = m.groups() + modulename, filters_ = split_filters(modulename_filter) filters = update_filters(filters, filters_) - return path, module_name, filters + return path, modulename, filters def _create_nav( @@ -219,7 +219,7 @@ def _create_nav( predicate: Callable[[str], bool] | None = None, depth: int = 0, ) -> list: - names = find_submodule_names(name, predicate) + names = find_submodulenames(name, predicate) tree: list = [callback(name, depth, is_package(name))] for sub in names: if not is_package(sub): @@ -234,8 +234,8 @@ def _create_nav( def _get_function(plugin: MkAPIPlugin, name: str) -> Callable | None: if fullname := plugin.config.get(name, None): - module_name, func_name = fullname.rsplit(".", maxsplit=1) - module = importlib.import_module(module_name) + modulename, func_name = fullname.rsplit(".", maxsplit=1) + module = importlib.import_module(modulename) return getattr(module, func_name) return None @@ -246,7 +246,7 @@ def _collect( plugin: MkAPIPlugin, ) -> tuple[list, list[Path]]: """Collect modules.""" - api_path, name, filters = _get_path_module_name_filters(item, plugin.config.filters) + api_path, name, filters = _get_path_modulename_filters(item, plugin.config.filters) abs_api_path = Path(config.docs_dir) / api_path Path.mkdir(abs_api_path, parents=True, exist_ok=True) diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 9ccbd225..5347f848 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -30,7 +30,7 @@ def is_package(name: str) -> bool: return False -def iter_submodule_names(name: str) -> Iterator[str]: +def iter_submodulenames(name: str) -> Iterator[str]: """Yield submodule names.""" spec = find_spec(name) if not spec or not spec.submodule_search_locations: @@ -41,7 +41,7 @@ def iter_submodule_names(name: str) -> Iterator[str]: yield f"{name}.{path.stem}" -def find_submodule_names( +def find_submodulenames( name: str, predicate: Callable[[str], bool] | None = None, ) -> list[str]: @@ -50,7 +50,7 @@ def find_submodule_names( Optionally, only return submodules that satisfy a given predicate. """ predicate = predicate or (lambda _: True) - names = [name for name in iter_submodule_names(name) if predicate(name)] + names = [name for name in iter_submodulenames(name) if predicate(name)] names.sort(key=lambda x: not is_package(x)) return names diff --git a/src_old/mkapi/core/object.py b/src_old/mkapi/core/object.py index 1f2d734e..0f59c1c0 100644 --- a/src_old/mkapi/core/object.py +++ b/src_old/mkapi/core/object.py @@ -28,9 +28,9 @@ def get_object(name: str) -> Any: # noqa: ANN401 """ names = name.split(".") for k in range(len(names), 0, -1): - module_name = ".".join(names[:k]) + modulename = ".".join(names[:k]) try: - obj = importlib.import_module(module_name) + obj = importlib.import_module(modulename) except ModuleNotFoundError: continue for attr in names[k:]: diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index fadc8ca2..d4922853 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -10,9 +10,9 @@ def test_parameters(): cls = get_object("mkapi.objects.Class") assert isinstance(cls, Class) assert is_dataclass(cls) - module_name = cls.get_module_name() - assert module_name - module_obj = importlib.import_module(module_name) + modulename = cls.get_modulename() + assert modulename + module_obj = importlib.import_module(modulename) members = dict(inspect.getmembers(module_obj, inspect.isclass)) assert cls.name cls_obj = members[cls.name] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 31da9eb9..fca1bb8e 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -14,7 +14,7 @@ MkAPIConfig, MkAPIPlugin, _create_nav, - _get_path_module_name_filters, + _get_path_modulename_filters, _insert_sys_path, _on_config_plugin, _walk_nav, @@ -129,12 +129,12 @@ def create_pages(item: str) -> list: assert nav[4] == {"API": "a"} -def test_get_path_module_name_filters(): - p, m, f = _get_path_module_name_filters("/a.b|f1|f2", ["f"]) +def test_get_path_modulename_filters(): + p, m, f = _get_path_modulename_filters("/a.b|f1|f2", ["f"]) assert p == "api" assert m == "a.b" assert f == ["f", "f1", "f2"] - p, m, f = _get_path_module_name_filters("/a", ["f"]) + p, m, f = _get_path_modulename_filters("/a", ["f"]) assert p == "api" assert m == "a" assert f == ["f"] @@ -188,8 +188,8 @@ def on_config(config, mkapi): return config -def module_title(module_name: str) -> str: - return module_name.rsplit(".")[-1] +def module_title(modulename: str) -> str: + return modulename.rsplit(".")[-1] def section_title(package_name: str) -> str: diff --git a/tests/test_utils.py b/tests/test_utils.py index 2239ed0a..04bb08c5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ from mkapi.utils import ( - find_submodule_names, + find_submodulenames, is_package, - iter_submodule_names, + iter_submodulenames, ) @@ -11,17 +11,17 @@ def test_is_package(): assert not is_package("mkapi.objects") -def test_iter_submodule_names(): - for name in iter_submodule_names("mkdocs"): +def test_iter_submodulenames(): + for name in iter_submodulenames("mkdocs"): assert name.startswith("mkdocs.") - for name in iter_submodule_names("mkdocs.structure"): + for name in iter_submodulenames("mkdocs.structure"): assert name.startswith("mkdocs.structure") -def test_find_submodule_names(): - names = find_submodule_names("mkdocs", lambda x: "tests" not in x) +def test_find_submodulenames(): + names = find_submodulenames("mkdocs", lambda x: "tests" not in x) assert "mkdocs.plugins" in names assert "mkdocs.tests" not in names - names = find_submodule_names("mkdocs", is_package) + names = find_submodulenames("mkdocs", is_package) assert "mkdocs.structure" in names assert "mkdocs.plugins" not in names From 1dd893b5a669d234de26d7ca775b69ea62f3712d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 09:15:47 +0900 Subject: [PATCH 073/148] Delete parent --- src/mkapi/objects.py | 114 +++++++++++++++++++------------ tests/dataclasses/test_params.py | 20 +++--- tests/objects/test_iter.py | 3 + tests/objects/test_object.py | 12 ++-- 4 files changed, 90 insertions(+), 59 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index f4cf3a82..2cf8f3c8 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -21,6 +21,7 @@ MODULENAMES: list[str | None] = [None] +QUALNAMES: list[str | None] = [None] def _push_modulename(modulename: str | None) -> None: @@ -35,17 +36,34 @@ def _get_current_modulename() -> str | None: return MODULENAMES[-1] +def _push_classname(name: str | None) -> None: + qualname = f"{QUALNAMES[-1]}.{name}" if QUALNAMES[-1] else name + QUALNAMES.append(qualname) + + +def _pop_classname() -> str | None: + return QUALNAMES.pop() + + +def _get_current_qualname() -> str | None: + return QUALNAMES[-1] + + @dataclass class Object: # noqa: D101 _node: ast.AST name: str - modulename: str | None = field(default_factory=_get_current_modulename, init=False) - # qualname: str | None = field(init=False) - # fullname: str | None = field(init=False) + modulename: str | None = field(init=False) + qualname: str = field(init=False) + fullname: str = field(init=False) docstring: str | None - # def __post_init__(self) -> None: # Set parent module name. - # self.__dict__["__modulename__"] = CURRENT_MODULE_NAME[0] + def __post_init__(self) -> None: + qualname = _get_current_qualname() + self.qualname = f"{qualname}.{self.name}" if qualname else self.name + modulename = _get_current_modulename() + self.modulename = modulename + self.fullname = f"{modulename}.{self.qualname}" if modulename else self.qualname def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -89,11 +107,13 @@ class Import(Object): """Import class for [Module].""" _node: ast.Import | ast.ImportFrom - docstring: str | None fullname: str from_: str | None object: Module | Class | Function | Attribute | Import | None # noqa: A003 + def __post_init__(self) -> None: # To avoid overwriting the fullname. + pass + def get_fullname(self) -> str: # noqa: D102 return self.fullname @@ -105,7 +125,7 @@ def iter_imports(node: ast.Module) -> Iterator[Import]: for alias in child.names: name = alias.asname or alias.name fullname = f"{from_}.{alias.name}" if from_ else name - yield Import(child, name, None, fullname, from_, None) + yield Import(child, name, fullname, None, from_, None) @dataclass(repr=False) @@ -116,7 +136,7 @@ class Attribute(Object): type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None - parent: Class | Module | None + # parent: Class | Module | None def __iter__(self) -> Iterator[ast.expr | ast.type_param]: for expr in [self.type, self.default]: @@ -126,9 +146,10 @@ def __iter__(self) -> Iterator[ast.expr | ast.type_param]: yield from self.type_params def get_fullname(self) -> str: # noqa: D102 - if self.parent: - return f"{self.parent.get_fullname()}.{self.name}" - return f"...{self.name}" + return self.fullname or self.name + # if self.parent: + # return f"{self.parent.get_fullname()}.{self.name}" + # return f"...{self.name}" def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: @@ -139,7 +160,7 @@ def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: type_ = mkapi.ast.get_assign_type(assign) value = None if isinstance(assign, ast.TypeAlias) else assign.value type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None - yield Attribute(assign, name, assign.__doc__, type_, value, type_params, None) + yield Attribute(assign, name, assign.__doc__, type_, value, type_params) @dataclass(repr=False) @@ -213,7 +234,7 @@ class Callable(Object): # noqa: D101 raises: list[Raise] decorators: list[ast.expr] type_params: list[ast.type_param] - parent: Class | Module | None + # parent: Class | Module | None def __iter__(self) -> Iterator[Parameter | Raise]: """Yield member instances.""" @@ -233,9 +254,12 @@ def get_raise(self, name: str) -> Raise | None: return get_by_name(self.raises, name) def get_fullname(self) -> str: # noqa: D102 - if self.parent: - return f"{self.parent.get_fullname()}.{self.name}" - return f"...{self.name}" + return self.fullname or f"...{self.name}" + # modulename = self.modulename or "" + # return f"{modulename}.{self.qualname}" + # if self.parent: + # return f"{self.parent.get_fullname()}.{self.name}" + # return f"...{self.name}" def get_node_docstring(self) -> str | None: """Return the docstring of the node.""" @@ -301,16 +325,18 @@ def _get_callable_args( def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: """Return a [Class] or [Function] instancd.""" - args = (node.name, None, *_get_callable_args(node), None) + args = (node.name, None, *_get_callable_args(node)) return Function(node, *args, get_return(node)) def get_class(node: ast.ClassDef) -> Class: """Return a [Class] instance.""" - args = (node.name, None, *_get_callable_args(node), None) + _push_classname(node.name) + args = (node.name, None, *_get_callable_args(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) bases: list[Class] = [] + _pop_classname() return Class(node, *args, attrs, classes, functions, bases) @@ -359,11 +385,11 @@ def get_fullname(self, name: str | None = None) -> str | None: if not name: return self.name if obj := self.get(name): - return obj.get_fullname() + return obj.fullname if "." in name: name, attr = name.rsplit(".", maxsplit=1) if obj := self.get(name): - return f"{obj.get_fullname()}.{attr}" + return f"{obj.fullname}.{attr}" return None def get_source(self, maxline: int | None = None) -> str | None: @@ -418,24 +444,21 @@ def get_module_path(name: str) -> Path | None: return path -CACHE_MODULE: dict[str, tuple[Module | None, float]] = {} +CACHE_MODULE: dict[str, Module | None] = {} def get_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" - if name in CACHE_MODULE and not CACHE_MODULE[name][0]: - return None + if name in CACHE_MODULE: + return CACHE_MODULE[name] if not (path := get_module_path(name)): - CACHE_MODULE[name] = (None, 0) + CACHE_MODULE[name] = None return None - mtime = path.stat().st_mtime - if name in CACHE_MODULE and mtime == CACHE_MODULE[name][1]: - return CACHE_MODULE[name][0] with path.open("r", encoding="utf-8") as f: source = f.read() _push_modulename(name) module = get_module_from_source(source) - CACHE_MODULE[name] = (module, mtime) + CACHE_MODULE[name] = module module.name = name module.kind = "package" if path.stem == "__init__" else "module" _postprocess_module(module) @@ -460,7 +483,7 @@ def get_module_from_node(node: ast.Module) -> Module: return Module(node, None, None, imports, attrs, classes, functions, None, None) -CACHE_OBJECT: dict[str, Module | Class | Function | Attribute] = {} +CACHE_OBJECT: dict[str, Module | Class | Function | Attribute | None] = {} def _register_object(obj: Module | Class | Function | Attribute) -> None: @@ -469,16 +492,21 @@ def _register_object(obj: Module | Class | Function | Attribute) -> None: def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" + if fullname in CACHE_MODULE: + return CACHE_MODULE[fullname] if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] names = fullname.split(".") for k in range(1, len(names) + 1): modulename = ".".join(names[:k]) - if not get_module(modulename): - return None - if fullname in CACHE_OBJECT: + if get_module(modulename) and fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] + CACHE_OBJECT[fullname] = None return None + # if not get_module(modulename): + # return None + # if fullname in CACHE_OBJECT: + # return CACHE_OBJECT[fullname] def set_import_object(module: Module) -> None: @@ -522,7 +550,8 @@ def _move_property(obj: Class) -> None: doc = func.get_node_docstring() type_ = func.returns.type type_params = func.type_params - attr = Attribute(node, func.name, doc, type_, None, type_params, func.parent) + # attr = Attribute(node, func.name, doc, type_, None, type_params, func.parent) + attr = Attribute(node, func.name, doc, type_, None, type_params) _split_attribute_docstring(attr) obj.attributes.append(attr) obj.functions = funcs @@ -541,7 +570,8 @@ def _new( name: str, ) -> Attribute | Parameter | Raise: if cls is Attribute: - return Attribute(None, name, None, None, None, [], None) + # return Attribute(None, name, None, None, None, [], None) + return Attribute(None, name, None, None, None, []) if cls is Parameter: return Parameter(None, name, None, None, None, None) if cls is Raise: @@ -584,17 +614,17 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.docstring = docstring -def _set_parent_of_members(obj: Module | Class) -> None: - for attr in obj.attributes: - attr.parent = obj - for cls in obj.classes: - cls.parent = obj - for func in obj.functions: - func.parent = obj +# def _set_parent_of_members(obj: Module | Class) -> None: +# for attr in obj.attributes: +# attr.parent = obj +# for cls in obj.classes: +# cls.parent = obj +# for func in obj.functions: +# func.parent = obj def _postprocess(obj: Module | Class) -> None: - _set_parent_of_members(obj) + # _set_parent_of_members(obj) _register_object(obj) _merge_docstring(obj) for attr in obj.attributes: diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index d4922853..a04a10aa 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -48,13 +48,13 @@ def test_parameters(): assert ast.unparse(p[5].type) == "list[ast.expr]" assert p[6].name == "type_params" assert ast.unparse(p[6].type) == "list[ast.type_param]" - assert p[7].name == "parent" - assert ast.unparse(p[7].type) == "Class | Module | None" - assert p[8].name == "attributes" - assert ast.unparse(p[8].type) == "list[Attribute]" - assert p[9].name == "classes" - assert ast.unparse(p[9].type) == "list[Class]" - assert p[10].name == "functions" - assert ast.unparse(p[10].type) == "list[Function]" - assert p[11].name == "bases" - assert ast.unparse(p[11].type) == "list[Class]" + # assert p[7].name == "parent" + # assert ast.unparse(p[7].type) == "Class | Module | None" + assert p[7].name == "attributes" + assert ast.unparse(p[7].type) == "list[Attribute]" + assert p[8].name == "classes" + assert ast.unparse(p[8].type) == "list[Class]" + assert p[9].name == "functions" + assert ast.unparse(p[9].type) == "list[Function]" + assert p[10].name == "bases" + assert ast.unparse(p[10].type) == "list[Class]" diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index 1dd11bbe..dfd722f2 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -43,3 +43,6 @@ def test_iter_bases(module: Module): assert next(bases).name == "Object" assert next(bases).name == "Callable" assert next(bases).name == "Class" + print(module.modulename) + print(module.fullname) + # assert 0 diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 2f219be5..a656e2f3 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -57,7 +57,7 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): assert get_module("xxx") is None - assert mkapi.objects.CACHE_MODULE["xxx"] == (None, 0) + assert mkapi.objects.CACHE_MODULE["xxx"] is None assert get_module("markdown") assert "markdown" in mkapi.objects.CACHE_MODULE @@ -115,13 +115,11 @@ def test_cache(): assert c.get_module() is module assert f assert f.get_module() is module - CACHE_OBJECT.clear() - assert not get_object("mkapi.objects.Module.get_class") - CACHE_MODULE.clear() - assert get_object("mkapi.objects.Module.get_class") - + c2 = get_object("mkapi.objects.Object") + f2 = get_object("mkapi.objects.Module.get_class") + assert c is c2 + assert f is f2 -def test_get_module_check_mtime(): m1 = get_module("mkdocs.structure.files") m2 = get_module("mkdocs.structure.files") assert m1 is m2 From 244f1f8b72c39747af9f9901a946a6fb175e9684 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 12:12:44 +0900 Subject: [PATCH 074/148] modules --- src/mkapi/ast.py | 5 + src/mkapi/nodes.py | 1 + src/mkapi/objects.py | 231 +++++++++++++------------------- tests/docstrings/conftest.py | 25 +++- tests/docstrings/test_google.py | 45 ++++--- tests/docstrings/test_merge.py | 6 +- tests/docstrings/test_numpy.py | 41 +++--- tests/nodes/test_node.py | 8 +- tests/objects/test_class.py | 15 ++- tests/objects/test_import.py | 18 +-- tests/objects/test_iter.py | 12 +- tests/objects/test_merge.py | 6 +- tests/objects/test_object.py | 21 +-- 13 files changed, 215 insertions(+), 219 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index dd912a7b..53690104 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -134,6 +134,11 @@ def iter_callable_nodes( yield child +def is_property(decorators: list[ast.expr]) -> bool: + """Return True if one of decorators is property.""" + return any(ast.unparse(deco).startswith("property") for deco in decorators) + + # a1.b_2(c[d]) -> a1, b_2, c, d SPLIT_IDENTIFIER_PATTERN = re.compile(r"[\.\[\]\(\)|]|\s+") diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py index fae8efbe..e1587e68 100644 --- a/src/mkapi/nodes.py +++ b/src/mkapi/nodes.py @@ -65,6 +65,7 @@ def walk(self) -> Iterator[Node]: def get_node(name: str) -> Node: """Return a [Node] instance from the object name.""" obj = get_object(name) + print(obj) if not obj or not isinstance(obj, Module | Class | Function): raise NotImplementedError return _get_node(obj) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 2cf8f3c8..4ac5437d 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -1,4 +1,4 @@ -"""AST module.""" +"""Object module.""" from __future__ import annotations import ast @@ -7,7 +7,7 @@ import inspect from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import mkapi.ast from mkapi import docstrings @@ -16,54 +16,52 @@ if TYPE_CHECKING: from collections.abc import Iterator from inspect import _IntrospectableCallable, _ParameterKind + from typing import Any from mkapi.docstrings import Docstring, Item, Section -MODULENAMES: list[str | None] = [None] -QUALNAMES: list[str | None] = [None] +STACK_MODULENAMES: list[str] = [] +STACK_QUALNAMES: list[str | None] = [None] -def _push_modulename(modulename: str | None) -> None: - MODULENAMES.append(modulename) +def _push_modulename(modulename: str) -> None: + STACK_MODULENAMES.append(modulename) def _pop_modulename() -> str | None: - return MODULENAMES.pop() + return STACK_MODULENAMES.pop() def _get_current_modulename() -> str | None: - return MODULENAMES[-1] + return STACK_MODULENAMES[-1] if STACK_MODULENAMES else None def _push_classname(name: str | None) -> None: - qualname = f"{QUALNAMES[-1]}.{name}" if QUALNAMES[-1] else name - QUALNAMES.append(qualname) + qualname = f"{STACK_QUALNAMES[-1]}.{name}" if STACK_QUALNAMES[-1] else name + STACK_QUALNAMES.append(qualname) def _pop_classname() -> str | None: - return QUALNAMES.pop() + return STACK_QUALNAMES.pop() def _get_current_qualname() -> str | None: - return QUALNAMES[-1] + return STACK_QUALNAMES[-1] @dataclass -class Object: # noqa: D101 +class Object: + """Object base class.""" + _node: ast.AST name: str modulename: str | None = field(init=False) - qualname: str = field(init=False) - fullname: str = field(init=False) docstring: str | None def __post_init__(self) -> None: - qualname = _get_current_qualname() - self.qualname = f"{qualname}.{self.name}" if qualname else self.name modulename = _get_current_modulename() self.modulename = modulename - self.fullname = f"{modulename}.{self.qualname}" if modulename else self.qualname def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -82,7 +80,6 @@ def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: def get_modulename(self) -> str | None: """Return the module name.""" return self.modulename - # return self.__dict__["__modulename__"] def get_module(self) -> Module | None: """Return a [Module] instance.""" @@ -109,13 +106,6 @@ class Import(Object): _node: ast.Import | ast.ImportFrom fullname: str from_: str | None - object: Module | Class | Function | Attribute | Import | None # noqa: A003 - - def __post_init__(self) -> None: # To avoid overwriting the fullname. - pass - - def get_fullname(self) -> str: # noqa: D102 - return self.fullname def iter_imports(node: ast.Module) -> Iterator[Import]: @@ -125,42 +115,7 @@ def iter_imports(node: ast.Module) -> Iterator[Import]: for alias in child.names: name = alias.asname or alias.name fullname = f"{from_}.{alias.name}" if from_ else name - yield Import(child, name, fullname, None, from_, None) - - -@dataclass(repr=False) -class Attribute(Object): - """Atrribute class for [Module] and [Class].""" - - _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None - type: ast.expr | None # # noqa: A003 - default: ast.expr | None - type_params: list[ast.type_param] | None - # parent: Class | Module | None - - def __iter__(self) -> Iterator[ast.expr | ast.type_param]: - for expr in [self.type, self.default]: - if expr: - yield expr - if self.type_params: - yield from self.type_params - - def get_fullname(self) -> str: # noqa: D102 - return self.fullname or self.name - # if self.parent: - # return f"{self.parent.get_fullname()}.{self.name}" - # return f"...{self.name}" - - -def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: - """Yield assign nodes from the Module or Class AST node.""" - for assign in mkapi.ast.iter_assign_nodes(node): - if not (name := mkapi.ast.get_assign_name(assign)): - continue - type_ = mkapi.ast.get_assign_type(assign) - value = None if isinstance(assign, ast.TypeAlias) else assign.value - type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None - yield Attribute(assign, name, assign.__doc__, type_, value, type_params) + yield Import(child, name, None, fullname, from_) @dataclass(repr=False) @@ -226,15 +181,63 @@ def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: return Return(node.returns, "", None, node.returns) +CACHE_OBJECT: dict[str, Attribute | Class | Function | Module | None] = {} + + @dataclass(repr=False) -class Callable(Object): # noqa: D101 +class Member(Object): + """Member class for [Attribute], [Function], [Class], and [Module].""" + + qualname: str = field(init=False) + fullname: str = field(init=False) + + def __post_init__(self) -> None: + super().__post_init__() + qualname = _get_current_qualname() + self.qualname = f"{qualname}.{self.name}" if qualname else self.name + m_name = self.modulename + self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname + CACHE_OBJECT[self.fullname] = self # type:ignore + + +@dataclass(repr=False) +class Attribute(Member): + """Atrribute class for [Module] and [Class].""" + + _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None + type: ast.expr | None # # noqa: A003 + default: ast.expr | None + type_params: list[ast.type_param] | None + + def __iter__(self) -> Iterator[ast.expr | ast.type_param]: + for expr in [self.type, self.default]: + if expr: + yield expr + if self.type_params: + yield from self.type_params + + +def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: + """Yield assign nodes from the Module or Class AST node.""" + for assign in mkapi.ast.iter_assign_nodes(node): + if not (name := mkapi.ast.get_assign_name(assign)): + continue + type_ = mkapi.ast.get_assign_type(assign) + value = None if isinstance(assign, ast.TypeAlias) else assign.value + type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None + yield Attribute(assign, name, assign.__doc__, type_, value, type_params) + + +@dataclass(repr=False) +class Callable(Member): + """Callable class for [Class] and [Function].""" + _node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef docstring: Docstring | None parameters: list[Parameter] raises: list[Raise] decorators: list[ast.expr] type_params: list[ast.type_param] - # parent: Class | Module | None def __iter__(self) -> Iterator[Parameter | Raise]: """Yield member instances.""" @@ -253,14 +256,6 @@ def get_raise(self, name: str) -> Raise | None: """Return a [Riase] instance by the name.""" return get_by_name(self.raises, name) - def get_fullname(self) -> str: # noqa: D102 - return self.fullname or f"...{self.name}" - # modulename = self.modulename or "" - # return f"{modulename}.{self.qualname}" - # if self.parent: - # return f"{self.parent.get_fullname()}.{self.name}" - # return f"...{self.name}" - def get_node_docstring(self) -> str | None: """Return the docstring of the node.""" return ast.get_docstring(self._node) @@ -363,11 +358,10 @@ def get_callables( @dataclass(repr=False) -class Module(Object): +class Module(Member): """Module class.""" _node: ast.Module - name: str | None docstring: Docstring | None imports: list[Import] attributes: list[Attribute] @@ -376,6 +370,11 @@ class Module(Object): source: str | None kind: str | None + def __post_init__(self) -> None: + super().__post_init__() + self.qualname = self.fullname = self.name + modules[self.name] = self + def get_fullname(self, name: str | None = None) -> str | None: """Return the fullname of the module. @@ -444,56 +443,48 @@ def get_module_path(name: str) -> Path | None: return path -CACHE_MODULE: dict[str, Module | None] = {} +modules: dict[str, Module | None] = {} def get_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" - if name in CACHE_MODULE: - return CACHE_MODULE[name] + if name in modules: + return modules[name] if not (path := get_module_path(name)): - CACHE_MODULE[name] = None + modules[name] = None return None with path.open("r", encoding="utf-8") as f: source = f.read() - _push_modulename(name) - module = get_module_from_source(source) - CACHE_MODULE[name] = module - module.name = name + module = get_module_from_source(source, name) module.kind = "package" if path.stem == "__init__" else "module" - _postprocess_module(module) - _postprocess(module) - _pop_modulename() return module -def get_module_from_source(source: str) -> Module: +def get_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) - module = get_module_from_node(node) + module = get_module_from_node(node, name) module.source = source return module -def get_module_from_node(node: ast.Module) -> Module: +def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" + _push_modulename(name) imports = list(iter_imports(node)) attrs = list(iter_attributes(node)) classes, functions = get_callables(node) - return Module(node, None, None, imports, attrs, classes, functions, None, None) - - -CACHE_OBJECT: dict[str, Module | Class | Function | Attribute | None] = {} - - -def _register_object(obj: Module | Class | Function | Attribute) -> None: - CACHE_OBJECT[obj.get_fullname()] = obj # type: ignore + module = Module(node, name, None, imports, attrs, classes, functions, None, None) + _postprocess_module(module) + _postprocess(module) + _pop_modulename() + return module def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" - if fullname in CACHE_MODULE: - return CACHE_MODULE[fullname] + if fullname in modules: + return modules[fullname] if fullname in CACHE_OBJECT: return CACHE_OBJECT[fullname] names = fullname.split(".") @@ -503,27 +494,6 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: return CACHE_OBJECT[fullname] CACHE_OBJECT[fullname] = None return None - # if not get_module(modulename): - # return None - # if fullname in CACHE_OBJECT: - # return CACHE_OBJECT[fullname] - - -def set_import_object(module: Module) -> None: - """Set import object.""" - for import_ in module.imports: - _set_import_object(import_) - - -def _set_import_object(import_: Import) -> None: - if obj := get_module(import_.fullname): - import_.object = obj - return - if "." not in import_.fullname: - return - modulename, name = import_.fullname.rsplit(".", maxsplit=1) - if module := get_module(modulename): - import_.object = module.get(name) def _split_attribute_docstring(obj: Attribute) -> None: @@ -536,21 +506,18 @@ def _split_attribute_docstring(obj: Attribute) -> None: obj.docstring = desc -def _is_property(obj: Function) -> bool: - return any(ast.unparse(deco).startswith("property") for deco in obj.decorators) - - def _move_property(obj: Class) -> None: funcs: list[Function] = [] for func in obj.functions: node = func._node # noqa: SLF001 - if isinstance(node, ast.AsyncFunctionDef) or not _is_property(func): + if isinstance(node, ast.AsyncFunctionDef) or not mkapi.ast.is_property( + func.decorators, + ): funcs.append(func) continue doc = func.get_node_docstring() type_ = func.returns.type type_params = func.type_params - # attr = Attribute(node, func.name, doc, type_, None, type_params, func.parent) attr = Attribute(node, func.name, doc, type_, None, type_params) _split_attribute_docstring(attr) obj.attributes.append(attr) @@ -570,7 +537,6 @@ def _new( name: str, ) -> Attribute | Parameter | Raise: if cls is Attribute: - # return Attribute(None, name, None, None, None, [], None) return Attribute(None, name, None, None, None, []) if cls is Parameter: return Parameter(None, name, None, None, None, None) @@ -614,24 +580,11 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.docstring = docstring -# def _set_parent_of_members(obj: Module | Class) -> None: -# for attr in obj.attributes: -# attr.parent = obj -# for cls in obj.classes: -# cls.parent = obj -# for func in obj.functions: -# func.parent = obj - - def _postprocess(obj: Module | Class) -> None: - # _set_parent_of_members(obj) - _register_object(obj) _merge_docstring(obj) for attr in obj.attributes: - _register_object(attr) _split_attribute_docstring(attr) for func in obj.functions: - _register_object(func) _merge_docstring(func) for cls in obj.classes: _move_property(cls) diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index 44e88da3..af11f36a 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -4,7 +4,8 @@ import pytest -from mkapi.objects import get_module_from_node, get_module_path +from mkapi.ast import iter_callable_nodes +from mkapi.objects import get_module_path def get_module(name): @@ -15,8 +16,7 @@ def get_module(name): assert path with path.open("r", encoding="utf-8") as f: source = f.read() - node = ast.parse(source) - return get_module_from_node(node) + return ast.parse(source) @pytest.fixture(scope="module") @@ -27,3 +27,22 @@ def google(): @pytest.fixture(scope="module") def numpy(): return get_module("examples.styles.example_numpy") + + +@pytest.fixture(scope="module") +def get_node(): + def get_node(node, name): + for child in iter_callable_nodes(node): + if child.name == name: + return child + raise NameError + + return get_node + + +@pytest.fixture(scope="module") +def get(get_node): + def get(node, name): + return ast.get_docstring(get_node(node, name)) + + return get diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 26045b3e..2392088f 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -1,3 +1,5 @@ +import ast + from mkapi.docstrings import ( _iter_items, _iter_sections, @@ -7,7 +9,6 @@ split_section, split_without_name, ) -from mkapi.objects import Module def test_split_section(): @@ -32,8 +33,8 @@ def test_iter_sections_short(): assert sections == [("", "x")] -def test_iter_sections(google: Module): - doc = google.get_node_docstring() +def test_iter_sections(google): + doc = ast.get_docstring(google) assert isinstance(doc, str) sections = list(_iter_sections(doc, "google")) assert len(sections) == 6 @@ -54,8 +55,14 @@ def test_iter_sections(google: Module): assert sections[5][1].endswith(".html") -def test_iter_items(google: Module): - doc = google.get("module_level_function").get_node_docstring() # type: ignore +def test_iter_items(google, get): + doc = ast.get_docstring(google) + assert isinstance(doc, str) + section = list(_iter_sections(doc, "google"))[3][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + doc = get(google, "module_level_function") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] items = list(_iter_items(section)) @@ -64,16 +71,10 @@ def test_iter_items(google: Module): assert items[1].startswith("param2") assert items[2].startswith("*args") assert items[3].startswith("**kwargs") - doc = google.get_node_docstring() - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[3][1] - items = list(_iter_items(section)) - assert len(items) == 1 - assert items[0].startswith("module_") -def test_split_item(google: Module): - doc = google.get("module_level_function").get_node_docstring() # type: ignore +def test_split_item(google, get): + doc = get(google, "module_level_function") assert isinstance(doc, str) sections = list(_iter_sections(doc, "google")) section = sections[1][1] @@ -92,8 +93,8 @@ def test_split_item(google: Module): assert x[2].endswith("the interface.") -def test_iter_items_class(google: Module): - doc = google.get("ExampleClass").get_node_docstring() # type: ignore +def test_iter_items_class(google, get, get_node): + doc = get(google, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] x = list(iter_items(section, "google")) @@ -103,7 +104,7 @@ def test_iter_items_class(google: Module): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].text == "Description of `attr2`." - doc = google.get("ExampleClass").get("__init__").get_node_docstring() # type: ignore + doc = get(get_node(google, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = list(iter_items(section, "google")) @@ -113,8 +114,8 @@ def test_iter_items_class(google: Module): assert x[1].text == "Description of `param2`. Multiple\nlines are supported." -def test_split_without_name(google: Module): - doc = google.get("module_level_function").get_node_docstring() # type: ignore +def test_split_without_name(google, get): + doc = get(google, "module_level_function") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] x = split_without_name(section, "google") @@ -123,13 +124,13 @@ def test_split_without_name(google: Module): assert x[1].endswith(" }") -def test_repr(google: Module): - r = repr(parse(google.get_node_docstring(), "google")) # type: ignore +def test_repr(google): + r = repr(parse(ast.get_docstring(google), "google")) # type: ignore assert r == "Docstring(num_sections=6)" -def test_iter_items_raises(google: Module): - doc = google.get("module_level_function").get_node_docstring() # type: ignore +def test_iter_items_raises(google, get): + doc = get(google, "module_level_function") assert isinstance(doc, str) name, section = list(_iter_sections(doc, "google"))[3] assert name == "Raises" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index fbcda799..b6afa9c1 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -14,9 +14,9 @@ def test_iter_merged_items(): assert c[2].type == "list" -def test_merge(google): - a = parse(google.get("ExampleClass").get_node_docstring(), "google") - b = parse(google.get("ExampleClass").get("__init__").get_node_docstring(), "google") +def test_merge(google, get, get_node): + a = parse(get(google, "ExampleClass")) + b = parse(get(get_node(google, "ExampleClass"), "__init__")) doc = merge(a, b) assert doc assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index 58df8af0..b036c39c 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -1,3 +1,5 @@ +import ast + from mkapi.docstrings import ( _iter_items, _iter_sections, @@ -6,7 +8,6 @@ split_section, split_without_name, ) -from mkapi.objects import Module def test_split_section(): @@ -28,8 +29,8 @@ def test_iter_sections_short(): assert sections == [("", "x")] -def test_iter_sections(numpy: Module): - doc = numpy.get_node_docstring() +def test_iter_sections(numpy): + doc = ast.get_docstring(numpy) assert isinstance(doc, str) sections = list(_iter_sections(doc, "numpy")) assert len(sections) == 7 @@ -52,8 +53,14 @@ def test_iter_sections(numpy: Module): assert sections[6][1].endswith(".rst.txt") -def test_iter_items(numpy: Module): - doc = numpy.get("module_level_function").get_node_docstring() # type: ignore +def test_iter_items(numpy, get): + doc = ast.get_docstring(numpy) + assert isinstance(doc, str) + section = list(_iter_sections(doc, "numpy"))[5][1] + items = list(_iter_items(section)) + assert len(items) == 1 + assert items[0].startswith("module_") + doc = get(numpy, "module_level_function") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] items = list(_iter_items(section)) @@ -62,16 +69,10 @@ def test_iter_items(numpy: Module): assert items[1].startswith("param2") assert items[2].startswith("*args") assert items[3].startswith("**kwargs") - doc = numpy.get_node_docstring() - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[5][1] - items = list(_iter_items(section)) - assert len(items) == 1 - assert items[0].startswith("module_") -def test_split_item(numpy: Module): - doc = numpy.get("module_level_function").get_node_docstring() # type: ignore +def test_split_item(numpy, get): + doc = get(numpy, "module_level_function") assert isinstance(doc, str) sections = list(_iter_sections(doc, "numpy")) items = list(_iter_items(sections[1][1])) @@ -88,8 +89,8 @@ def test_split_item(numpy: Module): assert x[2].endswith("the interface.") -def test_iter_items_class(numpy: Module): - doc = numpy.get("ExampleClass").get_node_docstring() # type: ignore +def test_iter_items_class(numpy, get, get_node): + doc = get(numpy, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] x = list(iter_items(section, "numpy")) @@ -99,7 +100,7 @@ def test_iter_items_class(numpy: Module): assert x[1].name == "attr2" assert x[1].type == ":obj:`int`, optional" assert x[1].text == "Description of `attr2`." - doc = numpy.get("ExampleClass").get("__init__").get_node_docstring() # type: ignore + doc = get(get_node(numpy, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = list(iter_items(section, "numpy")) @@ -109,8 +110,8 @@ def test_iter_items_class(numpy: Module): assert x[1].text == "Description of `param2`. Multiple\nlines are supported." -def test_get_return(numpy: Module): - doc = numpy.get("module_level_function").get_node_docstring() # type: ignore +def test_get_return(numpy, get): + doc = get(numpy, "module_level_function") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] x = split_without_name(section, "numpy") @@ -119,8 +120,8 @@ def test_get_return(numpy: Module): assert x[1].endswith(" }") -def test_iter_items_raises(numpy: Module): - doc = numpy.get("module_level_function").get_node_docstring() # type: ignore +def test_iter_items_raises(numpy, get): + doc = get(numpy, "module_level_function") assert isinstance(doc, str) name, section = list(_iter_sections(doc, "numpy"))[3] assert name == "Raises" diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py index b1246476..5bf613d1 100644 --- a/tests/nodes/test_node.py +++ b/tests/nodes/test_node.py @@ -1,10 +1,10 @@ from mkapi.nodes import get_node -def test_node(): - node = get_node("mkdocs.plugins") - for m in node.walk(): - print(m) +# def test_node(): +# node = get_node("mkdocs.plugins") +# for m in node.walk(): +# print(m) # def test_property(): diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py index 4422aef8..089a66f7 100644 --- a/tests/objects/test_class.py +++ b/tests/objects/test_class.py @@ -4,11 +4,22 @@ def test_baseclasses(): cls = get_object("mkapi.plugins.MkAPIPlugin") assert isinstance(cls, Class) + assert cls.qualname == "MkAPIPlugin" + assert cls.fullname == "mkapi.plugins.MkAPIPlugin" + func = cls.get_function("on_config") + assert func + assert func.qualname == "MkAPIPlugin.on_config" + assert func.fullname == "mkapi.plugins.MkAPIPlugin.on_config" base = next(_iter_base_classes(cls)) assert base.name == "BasePlugin" - assert base.get_fullname() == "mkdocs.plugins.BasePlugin" + assert base.fullname == "mkdocs.plugins.BasePlugin" + func = base.get_function("on_config") + assert func + assert func.qualname == "BasePlugin.on_config" + assert func.fullname == "mkdocs.plugins.BasePlugin.on_config" cls = get_object("mkapi.plugins.MkAPIConfig") assert isinstance(cls, Class) base = next(_iter_base_classes(cls)) assert base.name == "Config" - assert base.get_fullname() == "mkdocs.config.base.Config" + assert base.qualname == "Config" + assert base.fullname == "mkdocs.config.base.Config" diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py index cfa12f37..fbfb8524 100644 --- a/tests/objects/test_import.py +++ b/tests/objects/test_import.py @@ -1,12 +1,12 @@ -from mkapi.objects import ( - Attribute, - Class, - Function, - Import, - Module, - get_module, - set_import_object, -) +# from mkapi.objects import ( +# Attribute, +# Class, +# Function, +# Import, +# Module, +# get_module, +# set_import_object, +# ) # def test_import(): diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index dfd722f2..5dc09742 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -14,7 +14,7 @@ def module(): def test_iter(module: Module): names = [o.name for o in module] - assert "CACHE_MODULE" in names + assert "modules" in names assert "Class" in names assert "get_object" in names @@ -31,9 +31,11 @@ def test_iter_exprs(module: Module): func = module.get("get_module_from_node") assert func exprs = list(func.iter_exprs()) - assert len(exprs) == 2 + assert len(exprs) == 4 assert ast.unparse(exprs[0]) == "ast.Module" - assert ast.unparse(exprs[1]) == "Module" + assert ast.unparse(exprs[1]) == "str" + assert ast.unparse(exprs[2]) == "'__mkapi__'" + assert ast.unparse(exprs[3]) == "Module" def test_iter_bases(module: Module): @@ -41,8 +43,6 @@ def test_iter_bases(module: Module): assert cls bases = cls.iter_bases() assert next(bases).name == "Object" + assert next(bases).name == "Member" assert next(bases).name == "Callable" assert next(bases).name == "Class" - print(module.modulename) - print(module.fullname) - # assert 0 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index 1cecf20e..54b0d182 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -4,13 +4,13 @@ from mkapi.docstrings import Docstring from mkapi.objects import ( - CACHE_MODULE, Attribute, Class, Function, Module, Parameter, get_module, + modules, ) from mkapi.utils import get_by_name @@ -43,8 +43,8 @@ def test_move_property_to_attributes(google): @pytest.fixture() def module(): name = "examples.styles.example_google" - if name in CACHE_MODULE: - del CACHE_MODULE[name] + if name in modules: + del modules[name] return get_module(name) diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index a656e2f3..2a8fa37d 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -6,12 +6,15 @@ import mkapi.objects from mkapi.ast import iter_callable_nodes, iter_import_nodes from mkapi.objects import ( - CACHE_MODULE, CACHE_OBJECT, + Class, + Function, + Module, get_module, get_module_path, get_object, iter_imports, + modules, ) @@ -57,9 +60,9 @@ def test_iter_definition_nodes(def_nodes): def test_not_found(): assert get_module("xxx") is None - assert mkapi.objects.CACHE_MODULE["xxx"] is None + assert mkapi.objects.modules["xxx"] is None assert get_module("markdown") - assert "markdown" in mkapi.objects.CACHE_MODULE + assert "markdown" in mkapi.objects.modules def test_repr(): @@ -97,16 +100,18 @@ def test_get_module_from_object(): assert module is m -def test_get_fullname(google): +def test_fullname(google: Module): c = google.get_class("ExampleClass") + assert isinstance(c, Class) f = c.get_function("example_method") - assert c.get_fullname() == "examples.styles.example_google.ExampleClass" + assert isinstance(f, Function) + assert c.fullname == "examples.styles.example_google.ExampleClass" name = "examples.styles.example_google.ExampleClass.example_method" - assert f.get_fullname() == name + assert f.fullname == name def test_cache(): - CACHE_MODULE.clear() + modules.clear() CACHE_OBJECT.clear() module = get_module("mkapi.objects") c = get_object("mkapi.objects.Object") @@ -123,7 +128,7 @@ def test_cache(): m1 = get_module("mkdocs.structure.files") m2 = get_module("mkdocs.structure.files") assert m1 is m2 - CACHE_MODULE.clear() + modules.clear() m3 = get_module("mkdocs.structure.files") m4 = get_module("mkdocs.structure.files") assert m2 is not m3 From 00e38124832b4ed98d3344add301e038aec2a7c6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 13:37:05 +0900 Subject: [PATCH 075/148] Use contextmanager --- src/mkapi/objects.py | 106 ++++++++++++++++------------------ tests/objects/test_inherit.py | 7 +++ tests/objects/test_iter.py | 2 +- tests/objects/test_object.py | 4 +- 4 files changed, 61 insertions(+), 58 deletions(-) create mode 100644 tests/objects/test_inherit.py diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 4ac5437d..ac271ed1 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,9 +5,10 @@ import importlib import importlib.util import inspect +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import mkapi.ast from mkapi import docstrings @@ -21,35 +22,6 @@ from mkapi.docstrings import Docstring, Item, Section -STACK_MODULENAMES: list[str] = [] -STACK_QUALNAMES: list[str | None] = [None] - - -def _push_modulename(modulename: str) -> None: - STACK_MODULENAMES.append(modulename) - - -def _pop_modulename() -> str | None: - return STACK_MODULENAMES.pop() - - -def _get_current_modulename() -> str | None: - return STACK_MODULENAMES[-1] if STACK_MODULENAMES else None - - -def _push_classname(name: str | None) -> None: - qualname = f"{STACK_QUALNAMES[-1]}.{name}" if STACK_QUALNAMES[-1] else name - STACK_QUALNAMES.append(qualname) - - -def _pop_classname() -> str | None: - return STACK_QUALNAMES.pop() - - -def _get_current_qualname() -> str | None: - return STACK_QUALNAMES[-1] - - @dataclass class Object: """Object base class.""" @@ -60,7 +32,7 @@ class Object: docstring: str | None def __post_init__(self) -> None: - modulename = _get_current_modulename() + modulename = Module.get_modulename() self.modulename = modulename def __repr__(self) -> str: @@ -181,7 +153,7 @@ def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: return Return(node.returns, "", None, node.returns) -CACHE_OBJECT: dict[str, Attribute | Class | Function | Module | None] = {} +objects: dict[str, Attribute | Class | Function | Module | None] = {} @dataclass(repr=False) @@ -193,11 +165,11 @@ class Member(Object): def __post_init__(self) -> None: super().__post_init__() - qualname = _get_current_qualname() + qualname = Class.get_qualclassname() self.qualname = f"{qualname}.{self.name}" if qualname else self.name m_name = self.modulename self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname - CACHE_OBJECT[self.fullname] = self # type:ignore + objects[self.fullname] = self # type:ignore @dataclass(repr=False) @@ -283,6 +255,7 @@ class Class(Callable): classes: list[Class] functions: list[Function] bases: list[Class] + classnames: ClassVar[list[str | None]] = [None] def __iter__(self) -> Iterator[Parameter | Attribute | Class | Function | Raise]: """Yield member instances.""" @@ -309,8 +282,20 @@ def iter_bases(self) -> Iterator[Class]: yield from base.iter_bases() yield self + @classmethod + @contextmanager + def set_qualclassname(cls, name: str) -> Iterator[None]: # noqa: D102 + qualname = f"{cls.classnames[-1]}.{name}" if cls.classnames[-1] else name + cls.classnames.append(qualname) + yield + cls.classnames.pop() -def _get_callable_args( + @classmethod + def get_qualclassname(cls) -> str | None: # noqa: D102 + return cls.classnames[-1] + + +def _callable_args( node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, ) -> tuple[list[Parameter], list[Raise], list[ast.expr], list[ast.type_param]]: parameters = [] if isinstance(node, ast.ClassDef) else list(iter_parameters(node)) @@ -320,18 +305,17 @@ def _get_callable_args( def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: """Return a [Class] or [Function] instancd.""" - args = (node.name, None, *_get_callable_args(node)) + args = (node.name, None, *_callable_args(node)) return Function(node, *args, get_return(node)) def get_class(node: ast.ClassDef) -> Class: """Return a [Class] instance.""" - _push_classname(node.name) - args = (node.name, None, *_get_callable_args(node)) - attrs = list(iter_attributes(node)) - classes, functions = get_callables(node) - bases: list[Class] = [] - _pop_classname() + with Class.set_qualclassname(node.name): + args = (node.name, None, *_callable_args(node)) + attrs = list(iter_attributes(node)) + classes, functions = get_callables(node) + bases: list[Class] = [] return Class(node, *args, attrs, classes, functions, bases) @@ -369,6 +353,7 @@ class Module(Member): functions: list[Function] source: str | None kind: str | None + modulenames: ClassVar[list[str | None]] = [None] def __post_init__(self) -> None: super().__post_init__() @@ -428,6 +413,17 @@ def get_function(self, name: str) -> Function | None: """Return an [Function] instance by the name.""" return get_by_name(self.functions, name) + @classmethod + @contextmanager + def set_modulename(cls, name: str) -> Iterator[None]: # noqa: D102 + cls.modulenames.append(name) + yield + cls.modulenames.pop() + + @classmethod + def get_modulename(cls) -> str | None: # noqa: D102 + return cls.modulenames[-1] + def get_module_path(name: str) -> Path | None: """Return the source path of the module name.""" @@ -470,14 +466,14 @@ def get_module_from_source(source: str, name: str = "__mkapi__") -> Module: def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" - _push_modulename(name) - imports = list(iter_imports(node)) - attrs = list(iter_attributes(node)) - classes, functions = get_callables(node) - module = Module(node, name, None, imports, attrs, classes, functions, None, None) - _postprocess_module(module) - _postprocess(module) - _pop_modulename() + with Module.set_modulename(name): + imports = list(iter_imports(node)) + attrs = list(iter_attributes(node)) + classes, functions = get_callables(node) + args = (imports, attrs, classes, functions) + module = Module(node, name, None, *args, None, None) + _postprocess_module(module) + _postprocess(module) return module @@ -485,14 +481,14 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" if fullname in modules: return modules[fullname] - if fullname in CACHE_OBJECT: - return CACHE_OBJECT[fullname] + if fullname in objects: + return objects[fullname] names = fullname.split(".") for k in range(1, len(names) + 1): modulename = ".".join(names[:k]) - if get_module(modulename) and fullname in CACHE_OBJECT: - return CACHE_OBJECT[fullname] - CACHE_OBJECT[fullname] = None + if get_module(modulename) and fullname in objects: + return objects[fullname] + objects[fullname] = None return None diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py new file mode 100644 index 00000000..2413922f --- /dev/null +++ b/tests/objects/test_inherit.py @@ -0,0 +1,7 @@ +from mkapi.objects import get_object + + +def test_inherit(): + cls = get_object("mkapi.objects.Class") + print(cls) + # assert 0 diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index 5dc09742..9c25fc25 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -20,7 +20,7 @@ def test_iter(module: Module): def test_func(module: Module): - func = module.get("_get_callable_args") + func = module.get("_callable_args") assert func objs = list(func) assert isinstance(objs[0], Parameter) diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 2a8fa37d..36ad4868 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -6,7 +6,6 @@ import mkapi.objects from mkapi.ast import iter_callable_nodes, iter_import_nodes from mkapi.objects import ( - CACHE_OBJECT, Class, Function, Module, @@ -15,6 +14,7 @@ get_object, iter_imports, modules, + objects, ) @@ -112,7 +112,7 @@ def test_fullname(google: Module): def test_cache(): modules.clear() - CACHE_OBJECT.clear() + objects.clear() module = get_module("mkapi.objects") c = get_object("mkapi.objects.Object") f = get_object("mkapi.objects.Module.get_class") From bdd5aa0df6b1c031d87009a59a49981bda4352c1 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 14:31:15 +0900 Subject: [PATCH 076/148] before gen --- src/mkapi/ast.py | 12 ++++++++++++ src/mkapi/dataclasses.py | 2 +- src/mkapi/objects.py | 21 +++++++-------------- tests/dataclasses/test_params.py | 2 +- tests/objects/test_inherit.py | 20 +++++++++++++++++--- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 53690104..99e5404e 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -27,6 +27,18 @@ from inspect import _ParameterKind +# def iter_child_nodes(node: AST): +# for child in (it := ast.iter_child_nodes(node)): +# if isinstance(child, ast.Import | ImportFrom): +# yield child +# elif isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): +# yield child +# elif isinstance(child, AnnAssign | Assign | TypeAlias): +# yield from iter_assign_nodes(child, it) +# elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): +# yield from iter_import_nodes(child) + + def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: """Yield import nodes.""" for child in ast.iter_child_nodes(node): diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index bd058e8c..868a09ad 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -38,7 +38,7 @@ def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: for attr in base.attributes: attrs[attr.name] = attr # updated by subclasses. - if not (modulename := cls.get_modulename()): + if not (modulename := cls.modulename): raise NotImplementedError module = importlib.import_module(modulename) members = dict(inspect.getmembers(module, inspect.isclass)) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index ac271ed1..7a22ce4f 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -32,8 +32,7 @@ class Object: docstring: str | None def __post_init__(self) -> None: - modulename = Module.get_modulename() - self.modulename = modulename + self.modulename = Module.get_modulename() def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -49,22 +48,16 @@ def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: else: yield obj - def get_modulename(self) -> str | None: - """Return the module name.""" - return self.modulename - def get_module(self) -> Module | None: """Return a [Module] instance.""" - if modulename := self.get_modulename(): - return get_module(modulename) - return None + return get_module(self.modulename) if self.modulename else None def get_source(self, maxline: int | None = None) -> str | None: """Return the source code segment.""" - if not (module := self.get_module()) or not (source := module.source): - return None - start, stop = self._node.lineno - 1, self._node.end_lineno - return "\n".join(source.split("\n")[start:stop][:maxline]) + if (module := self.get_module()) and (source := module.source): + start, stop = self._node.lineno - 1, self._node.end_lineno + return "\n".join(source.split("\n")[start:stop][:maxline]) + return None def unparse(self) -> str: """Unparse the AST node and return a string expression.""" @@ -81,7 +74,7 @@ class Import(Object): def iter_imports(node: ast.Module) -> Iterator[Import]: - """Yield import nodes and names.""" + """Yield [Import] instances in an [ast.Module] node.""" for child in mkapi.ast.iter_import_nodes(node): from_ = f"{child.module}" if isinstance(child, ast.ImportFrom) else None for alias in child.names: diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index a04a10aa..3f5cd3cb 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -10,7 +10,7 @@ def test_parameters(): cls = get_object("mkapi.objects.Class") assert isinstance(cls, Class) assert is_dataclass(cls) - modulename = cls.get_modulename() + modulename = cls.modulename assert modulename module_obj = importlib.import_module(modulename) members = dict(inspect.getmembers(module_obj, inspect.isclass)) diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 2413922f..e48796c3 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -1,7 +1,21 @@ -from mkapi.objects import get_object +from mkapi.objects import Class, get_object def test_inherit(): - cls = get_object("mkapi.objects.Class") + cls = get_object("mkapi.objects.Member") + assert isinstance(cls, Class) print(cls) - # assert 0 + print(cls.bases) + print(cls.attributes) + print(cls.functions) + + +def test_it(): + def f(it): + next(it) + + it = iter(range(11)) + for x in it: + f(it) + print(x) + assert 0 From 2800db34de2b65a193103896104bb8cc8b98e62d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 16:09:11 +0900 Subject: [PATCH 077/148] pass --- src/mkapi/ast.py | 77 +++++++++------------- src/mkapi/objects.py | 121 ++++++++++++++++++---------------- tests/docstrings/conftest.py | 6 +- tests/objects/test_attr.py | 8 ++- tests/objects/test_inherit.py | 10 --- tests/objects/test_object.py | 25 +------ 6 files changed, 110 insertions(+), 137 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 99e5404e..cf234889 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -13,7 +13,6 @@ FunctionDef, Import, ImportFrom, - Module, Name, NodeTransformer, TypeAlias, @@ -26,26 +25,22 @@ from collections.abc import Callable, Iterator from inspect import _ParameterKind +type Import_ = Import | ImportFrom +type Def = AsyncFunctionDef | FunctionDef | ClassDef +type Assign_ = AnnAssign | Assign | TypeAlias +type Node = Import_ | Def | Assign_ -# def iter_child_nodes(node: AST): -# for child in (it := ast.iter_child_nodes(node)): -# if isinstance(child, ast.Import | ImportFrom): -# yield child -# elif isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): -# yield child -# elif isinstance(child, AnnAssign | Assign | TypeAlias): -# yield from iter_assign_nodes(child, it) -# elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): -# yield from iter_import_nodes(child) - -def iter_import_nodes(node: AST) -> Iterator[Import | ImportFrom]: - """Yield import nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, ast.Import | ImportFrom): +def iter_child_nodes(node: AST) -> Iterator[Node]: # noqa: D103 + for child in (it := ast.iter_child_nodes(node)): + if isinstance(child, Import | ImportFrom): # noqa: SIM114 + yield child + elif isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): yield child - elif not isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): - yield from iter_import_nodes(child) + elif isinstance(child, AnnAssign | Assign | TypeAlias): + yield from _iter_assign_nodes(child, it) + else: + yield from iter_child_nodes(child) def _get_pseudo_docstring(node: AST) -> str | None: @@ -55,25 +50,26 @@ def _get_pseudo_docstring(node: AST) -> str | None: return cleandoc(doc) if isinstance(doc, str) else None -def iter_assign_nodes( - node: Module | ClassDef, -) -> Iterator[AnnAssign | Assign | TypeAlias]: +def _iter_assign_nodes( + node: AnnAssign | Assign | TypeAlias, + it: Iterator[AST], +) -> Iterator[Node]: """Yield assign nodes.""" - assign_node: AnnAssign | Assign | TypeAlias | None = None - for child in ast.iter_child_nodes(node): - if isinstance(child, AnnAssign | Assign | TypeAlias): - if assign_node: - yield assign_node - child.__doc__ = None - assign_node = child - else: - if assign_node: - assign_node.__doc__ = _get_pseudo_docstring(child) - yield assign_node - assign_node = None - if assign_node: - assign_node.__doc__ = None - yield assign_node + node.__doc__ = None + try: + next_node = next(it) + except StopIteration: + yield node + return + if isinstance(next_node, AnnAssign | Assign | TypeAlias): + yield node + yield from _iter_assign_nodes(next_node, it) + elif isinstance(next_node, AsyncFunctionDef | FunctionDef | ClassDef): + yield node + yield next_node + else: + node.__doc__ = _get_pseudo_docstring(next_node) + yield node def get_assign_name(node: AnnAssign | Assign | TypeAlias) -> str | None: @@ -137,15 +133,6 @@ def iter_parameters( yield arg, kind, default -def iter_callable_nodes( - node: Module | ClassDef, -) -> Iterator[FunctionDef | AsyncFunctionDef | ClassDef]: - """Yield callable nodes.""" - for child in ast.iter_child_nodes(node): - if isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): - yield child - - def is_property(decorators: list[ast.expr]) -> bool: """Return True if one of decorators is property.""" return any(ast.unparse(deco).startswith("property") for deco in decorators) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 7a22ce4f..22707a90 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from collections.abc import Iterator from inspect import _IntrospectableCallable, _ParameterKind - from typing import Any + from typing import Any, Self from mkapi.docstrings import Docstring, Item, Section @@ -73,14 +73,13 @@ class Import(Object): from_: str | None -def iter_imports(node: ast.Module) -> Iterator[Import]: - """Yield [Import] instances in an [ast.Module] node.""" - for child in mkapi.ast.iter_import_nodes(node): - from_ = f"{child.module}" if isinstance(child, ast.ImportFrom) else None - for alias in child.names: - name = alias.asname or alias.name - fullname = f"{from_}.{alias.name}" if from_ else name - yield Import(child, name, None, fullname, from_) +def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: + """Yield [Import] instances.""" + from_ = f"{node.module}" if isinstance(node, ast.ImportFrom) else None + for alias in node.names: + name = alias.asname or alias.name + fullname = f"{from_}.{alias.name}" if from_ else name + yield Import(node, name, None, fullname, from_) @dataclass(repr=False) @@ -182,15 +181,13 @@ def __iter__(self) -> Iterator[ast.expr | ast.type_param]: yield from self.type_params -def iter_attributes(node: ast.Module | ast.ClassDef) -> Iterator[Attribute]: - """Yield assign nodes from the Module or Class AST node.""" - for assign in mkapi.ast.iter_assign_nodes(node): - if not (name := mkapi.ast.get_assign_name(assign)): - continue - type_ = mkapi.ast.get_assign_type(assign) - value = None if isinstance(assign, ast.TypeAlias) else assign.value - type_params = assign.type_params if isinstance(assign, ast.TypeAlias) else None - yield Attribute(assign, name, assign.__doc__, type_, value, type_params) +def get_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: + """Return an [Attribute] instance.""" + name = mkapi.ast.get_assign_name(node) or "" + type_ = mkapi.ast.get_assign_type(node) + value = None if isinstance(node, ast.TypeAlias) else node.value + type_params = node.type_params if isinstance(node, ast.TypeAlias) else None + return Attribute(node, name, node.__doc__, type_, value, type_params) @dataclass(repr=False) @@ -257,6 +254,14 @@ def __iter__(self) -> Iterator[Parameter | Attribute | Class | Function | Raise] yield from self.classes yield from self.functions + def add_member(self, member: Attribute | Class | Function | Import): + if isinstance(member, Attribute): + self.attributes.append(member) + elif isinstance(member, Class): + self.classes.append(member) + elif isinstance(member, Function): + self.functions.append(member) + def get_attribute(self, name: str) -> Attribute | None: """Return an [Attribute] instance by the name.""" return get_by_name(self.attributes, name) @@ -277,10 +282,13 @@ def iter_bases(self) -> Iterator[Class]: @classmethod @contextmanager - def set_qualclassname(cls, name: str) -> Iterator[None]: # noqa: D102 + def create(cls, node: ast.ClassDef) -> Iterator[Self]: # noqa: D102 + name = node.name + args = (name, None, *_callable_args(node)) + klass = cls(node, *args, [], [], [], []) qualname = f"{cls.classnames[-1]}.{name}" if cls.classnames[-1] else name cls.classnames.append(qualname) - yield + yield klass cls.classnames.pop() @classmethod @@ -302,36 +310,28 @@ def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: return Function(node, *args, get_return(node)) -def get_class(node: ast.ClassDef) -> Class: - """Return a [Class] instance.""" - with Class.set_qualclassname(node.name): - args = (node.name, None, *_callable_args(node)) - attrs = list(iter_attributes(node)) - classes, functions = get_callables(node) - bases: list[Class] = [] - return Class(node, *args, attrs, classes, functions, bases) - - -def iter_callables(node: ast.Module | ast.ClassDef) -> Iterator[Class | Function]: - """Yield classes or functions.""" - for child in mkapi.ast.iter_callable_nodes(node): - if isinstance(child, ast.ClassDef): - yield get_class(child) - else: +def iter_members( + node: ast.ClassDef | ast.Module, +) -> Iterator[Attribute | Class | Function | Import]: + for child in mkapi.ast.iter_child_nodes(node): + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): yield get_function(child) + elif isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): + attr = get_attribute(child) + if attr.name: + yield attr + elif isinstance(child, ast.ClassDef): + yield get_class(child) + elif isinstance(child, ast.Import | ast.ImportFrom): + yield from iter_imports(child) -def get_callables( - node: ast.Module | ast.ClassDef, -) -> tuple[list[Class], list[Function]]: - """Return a tuple of (list[Class], list[Function]).""" - classes, functions = [], [] - for callable_node in iter_callables(node): - if isinstance(callable_node, Class): - classes.append(callable_node) - else: - functions.append(callable_node) - return classes, functions +def get_class(node: ast.ClassDef) -> Class: + """Return a [Class] instance.""" + with Class.create(node) as cls: + for member in iter_members(node): + cls.add_member(member) + return cls @dataclass(repr=False) @@ -386,6 +386,17 @@ def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: yield from self.classes yield from self.functions + def add_member(self, member: Import | Attribute | Class | Function) -> None: + """Add member instance.""" + if isinstance(member, Import): + self.imports.append(member) + elif isinstance(member, Attribute): + self.attributes.append(member) + elif isinstance(member, Class): + self.classes.append(member) + elif isinstance(member, Function): + self.functions.append(member) + def get(self, name: str) -> Import | Attribute | Class | Function | None: """Return a member instance by the name.""" return get_by_name(self, name) @@ -408,9 +419,9 @@ def get_function(self, name: str) -> Function | None: @classmethod @contextmanager - def set_modulename(cls, name: str) -> Iterator[None]: # noqa: D102 + def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 cls.modulenames.append(name) - yield + yield cls(node, name, None, [], [], [], [], None, None) cls.modulenames.pop() @classmethod @@ -453,18 +464,18 @@ def get_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) module = get_module_from_node(node, name) + print(module) + for c in module.classes: + print(c, c.name, c.fullname) module.source = source return module def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" - with Module.set_modulename(name): - imports = list(iter_imports(node)) - attrs = list(iter_attributes(node)) - classes, functions = get_callables(node) - args = (imports, attrs, classes, functions) - module = Module(node, name, None, *args, None, None) + with Module.create(node, name) as module: + for member in iter_members(node): + module.add_member(member) _postprocess_module(module) _postprocess(module) return module diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index af11f36a..5c5e1fd4 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -4,7 +4,7 @@ import pytest -from mkapi.ast import iter_callable_nodes +from mkapi.ast import iter_child_nodes from mkapi.objects import get_module_path @@ -32,7 +32,9 @@ def numpy(): @pytest.fixture(scope="module") def get_node(): def get_node(node, name): - for child in iter_callable_nodes(node): + for child in iter_child_nodes(node): + if not isinstance(child, ast.FunctionDef | ast.ClassDef): + continue if child.name == name: return child raise NameError diff --git a/tests/objects/test_attr.py b/tests/objects/test_attr.py index 61e738c8..dfc53938 100644 --- a/tests/objects/test_attr.py +++ b/tests/objects/test_attr.py @@ -1,17 +1,18 @@ import ast -from mkapi.objects import iter_attributes +from mkapi.objects import Attribute, iter_members def _get_attributes(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.ClassDef) - return list(iter_attributes(node)) + return list(iter_members(node)) def test_get_attributes(): src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" x = _get_attributes(src)[0] + assert isinstance(x, Attribute) assert x.type is None assert isinstance(x.default, ast.Call) assert ast.unparse(x.default.func) == "f.g" @@ -19,6 +20,9 @@ def test_get_attributes(): src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" assigns = _get_attributes(src) x, y, z = assigns + assert isinstance(x, Attribute) + assert isinstance(y, Attribute) + assert isinstance(z, Attribute) assert x.docstring is None assert x.default is None assert y.docstring == "docstring\na" diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index e48796c3..7276933b 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -8,14 +8,4 @@ def test_inherit(): print(cls.bases) print(cls.attributes) print(cls.functions) - - -def test_it(): - def f(it): - next(it) - - it = iter(range(11)) - for x in it: - f(it) - print(x) assert 0 diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 36ad4868..39c8a0bd 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -4,7 +4,7 @@ import mkapi.ast import mkapi.objects -from mkapi.ast import iter_callable_nodes, iter_import_nodes +from mkapi.ast import iter_child_nodes from mkapi.objects import ( Class, Function, @@ -28,7 +28,7 @@ def module(): def test_iter_import_nodes(module: ast.Module): - node = next(iter_import_nodes(module)) + node = next(iter_child_nodes(module)) assert isinstance(node, ast.ImportFrom) assert len(node.names) == 1 alias = node.names[0] @@ -37,27 +37,6 @@ def test_iter_import_nodes(module: ast.Module): assert alias.asname is None -def test_get_import_names(module: ast.Module): - it = iter_imports(module) - names = {im.name: im.fullname for im in it} - assert "logging" in names - assert names["logging"] == "logging" - assert "PurePath" in names - assert names["PurePath"] == "pathlib.PurePath" - assert "urlquote" in names - assert names["urlquote"] == "urllib.parse.quote" - - -@pytest.fixture(scope="module") -def def_nodes(module: ast.Module): - return list(iter_callable_nodes(module)) - - -def test_iter_definition_nodes(def_nodes): - assert any(node.name == "get_files" for node in def_nodes) - assert any(node.name == "Files" for node in def_nodes) - - def test_not_found(): assert get_module("xxx") is None assert mkapi.objects.modules["xxx"] is None From f6cbadee79feb759e4f352905b581d4036bcd2cf Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 17:27:33 +0900 Subject: [PATCH 078/148] pass --- src/mkapi/objects.py | 102 +++++++++++++++++----------------- tests/objects/test_inherit.py | 3 +- 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 22707a90..63efa502 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -184,10 +184,37 @@ def __iter__(self) -> Iterator[ast.expr | ast.type_param]: def get_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: """Return an [Attribute] instance.""" name = mkapi.ast.get_assign_name(node) or "" + doc = node.__doc__ type_ = mkapi.ast.get_assign_type(node) - value = None if isinstance(node, ast.TypeAlias) else node.value + doc, type_ = _get_attribute_doc_type(doc, type_) + default = None if isinstance(node, ast.TypeAlias) else node.value type_params = node.type_params if isinstance(node, ast.TypeAlias) else None - return Attribute(node, name, node.__doc__, type_, value, type_params) + return Attribute(node, name, doc, type_, default, type_params) + + +def get_attribute_from_property(node: ast.FunctionDef) -> Attribute: + """Return an [Attribute] instance from a property.""" + name = node.name + doc = ast.get_docstring(node) + type_ = node.returns + doc, type_ = _get_attribute_doc_type(doc, type_) + default = None + type_params = node.type_params + return Attribute(node, name, doc, type_, default, type_params) + + +def _get_attribute_doc_type( + doc: str | None, + type_: ast.expr | None, +) -> tuple[str | None, ast.expr | None]: + if not doc: + return doc, type_ + type_doc, doc = docstrings.split_without_name(doc, "google") + if not type_ and type_doc: + # ex. list(str) -> list[str] + type_doc = type_doc.replace("(", "[").replace(")", "]") + type_ = mkapi.ast.get_expr(type_doc) + return doc, type_ @dataclass(repr=False) @@ -254,7 +281,8 @@ def __iter__(self) -> Iterator[Parameter | Attribute | Class | Function | Raise] yield from self.classes yield from self.functions - def add_member(self, member: Attribute | Class | Function | Import): + def add_member(self, member: Attribute | Class | Function | Import) -> None: + """Add a member.""" if isinstance(member, Attribute): self.attributes.append(member) elif isinstance(member, Class): @@ -310,28 +338,33 @@ def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: return Function(node, *args, get_return(node)) +def get_class(node: ast.ClassDef) -> Class: + """Return a [Class] instance.""" + with Class.create(node) as cls: + for member in iter_members(node): + cls.add_member(member) + return cls + + def iter_members( node: ast.ClassDef | ast.Module, -) -> Iterator[Attribute | Class | Function | Import]: +) -> Iterator[Import | Attribute | Class | Function]: + """Yield members.""" for child in mkapi.ast.iter_child_nodes(node): - if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): - yield get_function(child) + if isinstance(child, ast.FunctionDef): # noqa: SIM102 + if mkapi.ast.is_property(child.decorator_list): + yield get_attribute_from_property(child) + continue + if isinstance(child, ast.Import | ast.ImportFrom): + yield from iter_imports(child) elif isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): attr = get_attribute(child) if attr.name: yield attr elif isinstance(child, ast.ClassDef): yield get_class(child) - elif isinstance(child, ast.Import | ast.ImportFrom): - yield from iter_imports(child) - - -def get_class(node: ast.ClassDef) -> Class: - """Return a [Class] instance.""" - with Class.create(node) as cls: - for member in iter_members(node): - cls.add_member(member) - return cls + elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + yield get_function(child) @dataclass(repr=False) @@ -420,8 +453,9 @@ def get_function(self, name: str) -> Function | None: @classmethod @contextmanager def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 + module = cls(node, name, None, [], [], [], [], None, None) cls.modulenames.append(name) - yield cls(node, name, None, [], [], [], [], None, None) + yield module cls.modulenames.pop() @classmethod @@ -464,9 +498,6 @@ def get_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) module = get_module_from_node(node, name) - print(module) - for c in module.classes: - print(c, c.name, c.fullname) module.source = source return module @@ -496,34 +527,6 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: return None -def _split_attribute_docstring(obj: Attribute) -> None: - if doc := obj.docstring: - type_, desc = docstrings.split_without_name(doc, "google") - if not obj.type and type_: - # ex. list(str) -> list[str] - type_ = type_.replace("(", "[").replace(")", "]") - obj.type = mkapi.ast.get_expr(type_) - obj.docstring = desc - - -def _move_property(obj: Class) -> None: - funcs: list[Function] = [] - for func in obj.functions: - node = func._node # noqa: SLF001 - if isinstance(node, ast.AsyncFunctionDef) or not mkapi.ast.is_property( - func.decorators, - ): - funcs.append(func) - continue - doc = func.get_node_docstring() - type_ = func.returns.type - type_params = func.type_params - attr = Attribute(node, func.name, doc, type_, None, type_params) - _split_attribute_docstring(attr) - obj.attributes.append(attr) - obj.functions = funcs - - def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: if not obj.type and item.type: # ex. list(str) -> list[str] @@ -582,12 +585,9 @@ def _merge_docstring(obj: Module | Class | Function) -> None: def _postprocess(obj: Module | Class) -> None: _merge_docstring(obj) - for attr in obj.attributes: - _split_attribute_docstring(attr) for func in obj.functions: _merge_docstring(func) for cls in obj.classes: - _move_property(cls) _postprocess(cls) _postprocess_class(cls) diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 7276933b..510fdc70 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -4,8 +4,9 @@ def test_inherit(): cls = get_object("mkapi.objects.Member") assert isinstance(cls, Class) - print(cls) + print(cls.name, cls.fullname, cls.qualname) print(cls.bases) print(cls.attributes) print(cls.functions) + assert 0 From 4197068b8cc1e6080fbfa2333549a7eb7a66690f Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 10 Jan 2024 22:18:55 +0900 Subject: [PATCH 079/148] ImportFrom level --- src/mkapi/dataclasses.py | 17 +++------ src/mkapi/objects.py | 64 ++++++++++++++++---------------- src/mkapi/utils.py | 12 ++++++ tests/dataclasses/test_params.py | 41 +++++++------------- tests/objects/test_inherit.py | 64 ++++++++++++++++++++++++++++---- 5 files changed, 119 insertions(+), 79 deletions(-) diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index 868a09ad..a0717e3c 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -31,23 +31,18 @@ def is_dataclass(cls: Class, module: Module | None = None) -> bool: def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: """Yield tuples of ([Attribute], [_ParameterKind]) for dataclass signature.""" - attrs: dict[str, Attribute] = {} - for base in cls.iter_bases(): - if not is_dataclass(base): - raise NotImplementedError - for attr in base.attributes: - attrs[attr.name] = attr # updated by subclasses. - if not (modulename := cls.modulename): raise NotImplementedError - module = importlib.import_module(modulename) + try: + module = importlib.import_module(modulename) + except ModuleNotFoundError: + return members = dict(inspect.getmembers(module, inspect.isclass)) obj = members[cls.name] for param in inspect.signature(obj).parameters.values(): - if param.name not in attrs: - raise NotImplementedError - yield attrs[param.name], param.kind + if attr := cls.get_attribute(param.name): + yield attr, param.kind # -------------------------------------------------------------- diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 63efa502..b211fa90 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -4,20 +4,20 @@ import ast import importlib import importlib.util -import inspect from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar import mkapi.ast +import mkapi.dataclasses from mkapi import docstrings -from mkapi.utils import del_by_name, get_by_name, unique_names +from mkapi.utils import del_by_name, get_by_name, iter_parent_modulenames, unique_names if TYPE_CHECKING: from collections.abc import Iterator - from inspect import _IntrospectableCallable, _ParameterKind - from typing import Any, Self + from inspect import _ParameterKind + from typing import Self from mkapi.docstrings import Docstring, Item, Section @@ -71,6 +71,7 @@ class Import(Object): _node: ast.Import | ast.ImportFrom fullname: str from_: str | None + level: int def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: @@ -79,7 +80,11 @@ def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: for alias in node.names: name = alias.asname or alias.name fullname = f"{from_}.{alias.name}" if from_ else name - yield Import(node, name, None, fullname, from_) + if isinstance(node, ast.Import): + for parent in iter_parent_modulenames(fullname): + yield Import(node, parent, None, parent, None, 0) + else: + yield Import(node, name, None, fullname, from_, node.level) @dataclass(repr=False) @@ -422,6 +427,9 @@ def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: def add_member(self, member: Import | Attribute | Class | Function) -> None: """Add member instance.""" if isinstance(member, Import): + if member.level: + prefix = ".".join(self.name.split(".")[: member.level]) + member.fullname = f"{prefix}.{member.fullname}" self.imports.append(member) elif isinstance(member, Attribute): self.attributes.append(member) @@ -507,7 +515,6 @@ def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: with Module.create(node, name) as module: for member in iter_members(node): module.add_member(member) - _postprocess_module(module) _postprocess(module) return module @@ -623,38 +630,29 @@ def _iter_base_classes(cls: Class) -> Iterator[Class]: yield base +def _inherit(cls: Class, name: str) -> None: + members = {} + for base in cls.bases: + for member in getattr(base, name): + members[member.name] = member + for member in getattr(cls, name): + members[member.name] = member + setattr(cls, name, list(members.values())) + + def _postprocess_class(cls: Class) -> None: cls.bases = list(_iter_base_classes(cls)) + for name in ["attributes", "functions", "classes"]: + _inherit(cls, name) if init := cls.get_function("__init__"): cls.parameters = init.parameters cls.raises = init.raises cls.docstring = docstrings.merge(cls.docstring, init.docstring) cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") - # TODO: dataclass - - -def _get_function_from_callable(obj: _IntrospectableCallable) -> Function: - pass - # node = mkapi.ast.get_node_from_callable(obj) - # return get_function(node) - - -def _set_parameters_from_object(obj: Module | Class, members: dict[str, Any]) -> None: - for cls in obj.classes: - cls_obj = members[cls.name] - if callable(cls_obj): - func = _get_function_from_callable(cls_obj) - cls.parameters = func.parameters - # _set_parameters_from_object(cls, dict(inspect.getmembers(cls_obj))) - # if isinstance(obj, Class): - # for func in obj.functions: - # f = _get_function_from_object(members[func.name]) - # func.parameters = f.parameters - - -def _postprocess_module(module: Module) -> None: - return - module_obj = importlib.import_module(obj.name) - members = dict(inspect.getmembers(module_obj)) - _set_parameters_from_object(obj, members) + if mkapi.dataclasses.is_dataclass(cls): + for attr, kind in mkapi.dataclasses.iter_parameters(cls): + args = (None, attr.name, attr.docstring, attr.type, attr.default, kind) + parameter = Parameter(*args) + parameter.modulename = attr.modulename + cls.parameters.append(parameter) diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 5347f848..bdd04fbb 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -55,6 +55,18 @@ def find_submodulenames( return names +def iter_parent_modulenames(fullname: str) -> Iterator[str]: + """Yield parent module names. + + Examples: + >>> list(iter_parent_modulenames("a.b.c.d")) + ['a', 'a.b', 'a.b.c', 'a.b.c.d'] + """ + names = fullname.split(".") + for k in range(1, len(names) + 1): + yield ".".join(names[:k]) + + def delete_ptags(html: str) -> str: """Return HTML without

tag. diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index 3f5cd3cb..ae11a925 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -1,60 +1,45 @@ import ast -import importlib -import inspect from mkapi.dataclasses import is_dataclass -from mkapi.objects import Attribute, Class, Parameter, get_object +from mkapi.objects import Class, get_object def test_parameters(): cls = get_object("mkapi.objects.Class") assert isinstance(cls, Class) assert is_dataclass(cls) - modulename = cls.modulename - assert modulename - module_obj = importlib.import_module(modulename) - members = dict(inspect.getmembers(module_obj, inspect.isclass)) - assert cls.name - cls_obj = members[cls.name] - params = inspect.signature(cls_obj).parameters - bases = list(cls.iter_bases()) - attrs: dict[str, Attribute] = {} - for base in bases: - if not is_dataclass(base): - raise NotImplementedError - for attr in base.attributes: - attrs[attr.name] = attr # updated by subclasses. - ps = [] - for p in params.values(): - if p.name not in attrs: - raise NotImplementedError - attr = attrs[p.name] - args = (None, p.name, attr.docstring, attr.type, attr.default, p.kind) - parameter = Parameter(*args) - ps.append(parameter) - cls.parameters = ps p = cls.parameters + assert len(p) == 11 assert p[0].name == "_node" + assert p[0].type assert ast.unparse(p[0].type) == "ast.ClassDef" assert p[1].name == "name" + assert p[1].type assert ast.unparse(p[1].type) == "str" assert p[2].name == "docstring" + assert p[2].type assert ast.unparse(p[2].type) == "Docstring | None" assert p[3].name == "parameters" + assert p[3].type assert ast.unparse(p[3].type) == "list[Parameter]" assert p[4].name == "raises" + assert p[4].type assert ast.unparse(p[4].type) == "list[Raise]" assert p[5].name == "decorators" + assert p[5].type assert ast.unparse(p[5].type) == "list[ast.expr]" assert p[6].name == "type_params" + assert p[6].type assert ast.unparse(p[6].type) == "list[ast.type_param]" - # assert p[7].name == "parent" - # assert ast.unparse(p[7].type) == "Class | Module | None" assert p[7].name == "attributes" + assert p[7].type assert ast.unparse(p[7].type) == "list[Attribute]" assert p[8].name == "classes" + assert p[8].type assert ast.unparse(p[8].type) == "list[Class]" assert p[9].name == "functions" + assert p[9].type assert ast.unparse(p[9].type) == "list[Function]" assert p[10].name == "bases" + assert p[10].type assert ast.unparse(p[10].type) == "list[Class]" diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 510fdc70..f639c3bc 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -1,12 +1,62 @@ -from mkapi.objects import Class, get_object +from mkapi.objects import Class, get_module, get_object +from mkapi.utils import get_by_name def test_inherit(): - cls = get_object("mkapi.objects.Member") + cls = get_object("mkapi.objects.Class") assert isinstance(cls, Class) - print(cls.name, cls.fullname, cls.qualname) - print(cls.bases) - print(cls.attributes) - print(cls.functions) + assert cls.bases[0] + assert cls.bases[0].name == "Callable" + assert cls.bases[0].fullname == "mkapi.objects.Callable" + a = cls.attributes + assert len(a) == 15 + assert get_by_name(a, "_node") + assert get_by_name(a, "qualname") + assert get_by_name(a, "fullname") + assert get_by_name(a, "modulename") + assert get_by_name(a, "classnames") + p = cls.parameters + assert len(p) == 11 + assert get_by_name(p, "_node") + assert not get_by_name(p, "qualname") + assert not get_by_name(p, "fullname") + assert not get_by_name(p, "modulename") + assert not get_by_name(p, "classnames") - assert 0 + +def test_inherit_other_module(): + cls = get_object("mkapi.plugins.MkAPIConfig") + assert isinstance(cls, Class) + assert cls.bases[0].fullname == "mkdocs.config.base.Config" + a = cls.attributes + x = get_by_name(a, "config_file_path") + assert x + assert x.fullname == "mkdocs.config.base.Config.config_file_path" + + +def test_inherit_other_module2(): + cls = get_object("mkapi.plugins.MkAPIPlugin") + assert isinstance(cls, Class) + f = cls.functions + x = get_by_name(f, "on_pre_template") + assert x + p = x.parameters[1] + assert p.unparse() == "template: jinja2.Template" + m = p.get_module() + assert m + assert m.name == "mkdocs.plugins" + assert m.get("get_plugin_logger") + + +def test_inherit_other_module3(): + m = get_module("mkdocs.plugins") + assert m + assert m.get_fullname("TemplateContext") == "mkdocs.utils.templates.TemplateContext" + assert m.get_fullname("jinja2") == "jinja2" + a = "jinja2.environment.Template" + m2 = get_module("jinja2") + assert m2 + t = m2.get("Template") + assert t + assert t.fullname == a # broken + assert m.get_fullname("jinja2.Template") == a From 72ef458c008e503239e7d42eed13d34105b18708 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 11 Jan 2024 17:41:18 +0900 Subject: [PATCH 080/148] import --- pyproject.toml | 3 +- src/mkapi/ast.py | 37 ++++++++++++----------- src/mkapi/dataclasses.py | 3 +- src/mkapi/docstrings.py | 55 ++++++++++++++++++++--------------- src/mkapi/objects.py | 11 +++---- src/mkapi/utils.py | 7 +++-- tests/objects/test_inherit.py | 17 ++++++----- 7 files changed, 73 insertions(+), 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fff78ace..c504cf4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,11 +85,12 @@ ignore = [ "D105", "D406", "D407", + "EM102", "ERA001", "N812", "PGH003", + "PLR2004", "TRY003", - "EM102", ] exclude = ["examples"] diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index cf234889..481e26e6 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -32,10 +32,9 @@ def iter_child_nodes(node: AST) -> Iterator[Node]: # noqa: D103 + yield_type = Import | ImportFrom | AsyncFunctionDef | FunctionDef | ClassDef for child in (it := ast.iter_child_nodes(node)): - if isinstance(child, Import | ImportFrom): # noqa: SIM114 - yield child - elif isinstance(child, AsyncFunctionDef | FunctionDef | ClassDef): + if isinstance(child, yield_type): yield child elif isinstance(child, AnnAssign | Assign | TypeAlias): yield from _iter_assign_nodes(child, it) @@ -44,10 +43,10 @@ def iter_child_nodes(node: AST) -> Iterator[Node]: # noqa: D103 def _get_pseudo_docstring(node: AST) -> str | None: - if not isinstance(node, Expr) or not isinstance(node.value, Constant): - return None - doc = node.value.value - return cleandoc(doc) if isinstance(doc, str) else None + if isinstance(node, Expr) and isinstance(node.value, Constant): + doc = node.value.value + return cleandoc(doc) if isinstance(doc, str) else None + return None def _iter_assign_nodes( @@ -92,7 +91,7 @@ def get_assign_type(node: AnnAssign | Assign | TypeAlias) -> ast.expr | None: return None -PARAMETER_KIND_DICT: dict[_ParameterKind, str] = { +PARAMETER_KIND_ATTRIBUTE: dict[_ParameterKind, str] = { Parameter.POSITIONAL_ONLY: "posonlyargs", Parameter.POSITIONAL_OR_KEYWORD: "args", Parameter.VAR_POSITIONAL: "vararg", @@ -102,15 +101,15 @@ def get_assign_type(node: AnnAssign | Assign | TypeAlias) -> ast.expr | None: def _iter_parameters( - node: FunctionDef | AsyncFunctionDef, + node: AsyncFunctionDef | FunctionDef, ) -> Iterator[tuple[ast.arg, _ParameterKind]]: - for kind, attr in PARAMETER_KIND_DICT.items(): + for kind, attr in PARAMETER_KIND_ATTRIBUTE.items(): if args := getattr(node.args, attr): it = args if isinstance(args, list) else [args] yield from ((arg, kind) for arg in it) -def _iter_defaults(node: FunctionDef | AsyncFunctionDef) -> Iterator[ast.expr | None]: +def _iter_defaults(node: AsyncFunctionDef | FunctionDef) -> Iterator[ast.expr | None]: args = node.args num_positional = len(args.posonlyargs) + len(args.args) nones = [None] * num_positional @@ -119,22 +118,26 @@ def _iter_defaults(node: FunctionDef | AsyncFunctionDef) -> Iterator[ast.expr | def iter_parameters( - node: FunctionDef | AsyncFunctionDef, + node: AsyncFunctionDef | FunctionDef, ) -> Iterator[tuple[ast.arg, _ParameterKind, ast.expr | None]]: """Yield parameters from the function node.""" it = _iter_defaults(node) for arg, kind in _iter_parameters(node): - if kind is Parameter.VAR_POSITIONAL: - arg.arg, default = f"*{arg.arg}", None - elif kind is Parameter.VAR_KEYWORD: - arg.arg, default = f"**{arg.arg}", None + if kind in [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]: + default = None else: default = next(it) yield arg, kind, default + # if kind is Parameter.VAR_POSITIONAL: + # arg.arg, default = f"*{arg.arg}", None + # elif kind is Parameter.VAR_KEYWORD: + # arg.arg, default = f"**{arg.arg}", None + # else: + # default = next(it) def is_property(decorators: list[ast.expr]) -> bool: - """Return True if one of decorators is property.""" + """Return True if one of decorators is `property`.""" return any(ast.unparse(deco).startswith("property") for deco in decorators) diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index a0717e3c..89b75909 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -10,8 +10,9 @@ if TYPE_CHECKING: from inspect import _ParameterKind + from typing import Any - from mkapi.objects import Any, Attribute, Class, Iterator, Module + from mkapi.objects import Attribute, Class, Iterator, Module def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index b95c9811..6ab5c671 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -74,26 +74,29 @@ def iter_items( style: Style, section_name: str = "Parameters", ) -> Iterator[Item]: - """Yiled a tuple of (name, type, text) of item. + """Yiled a tuple of (name, type, text) of an item. - If name is 'Raises', the type of [Item] is set by its name. + If the section name is 'Raises', the type is set by its name. """ for item in _iter_items(section): name, type_, text = split_item(item, style) - if section_name != "Raises": - yield Item(name, type_, text) - else: - yield Item(name, name, text) + name = name.replace("*", "") # *args -> args, **kwargs -> kwargs + if section_name == "Raises": + type_ = name + yield Item(name, type_, text) @dataclass -class Section(Item): # noqa: D101 +class Section(Item): + """Section class of docstring.""" + items: list[Item] def __iter__(self) -> Iterator[Item]: return iter(self.items) - def get(self, name: str) -> Item | None: # noqa: D102 + def get(self, name: str) -> Item | None: + """Return an [Item] instance by name.""" return get_by_name(self.items, name) @@ -119,7 +122,7 @@ def _split_sections(doc: str, style: Style) -> Iterator[str]: # In Numpy style, if a section is indented, then a section break is # created by resuming unindented text. def _subsplit(doc: str, style: Style) -> list[str]: - if style == "google" or len(lines := doc.split("\n")) < 3: # noqa: PLR2004 + if style == "google" or len(lines := doc.split("\n")) < 3: return [doc] if not lines[2].startswith(" "): # 2 == after '----' line. return [doc] @@ -127,13 +130,14 @@ def _subsplit(doc: str, style: Style) -> list[str]: SECTION_NAMES: list[tuple[str, ...]] = [ - ("Parameters", "Arguments", "Args"), - ("Attributes",), + ("Parameters", "Parameter", "Params", "Param"), + ("Parameters", "Arguments", "Argument", "Args", "Arg"), + ("Attributes", "Attribute", "Attrs", "Attr"), ("Examples", "Example"), ("Returns", "Return"), ("Raises", "Raise"), ("Yields", "Yield"), - ("Warnings", "Warns"), + ("Warning", "Warn"), ("Warnings", "Warns"), ("Note",), ("Notes",), @@ -143,9 +147,9 @@ def _subsplit(doc: str, style: Style) -> list[str]: def get_style(doc: str) -> Style: - """Return the docstring style of doc. + """Return the docstring style of a doc. - If the style can't be determined, the current style returned. + If the style can't be determined, the current style is returned. """ for names in SECTION_NAMES: for name in names: @@ -164,9 +168,9 @@ def _rename_section(section_name: str) -> str: def split_section(section: str, style: Style) -> tuple[str, str]: - """Return section name and its text.""" + """Return a section name and its text.""" lines = section.split("\n") - if len(lines) < 2: # noqa: PLR2004 + if len(lines) < 2: return "", section if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): return lines[0][:-1], join_without_first_indent(lines[1:]) @@ -176,7 +180,7 @@ def split_section(section: str, style: Style) -> tuple[str, str]: def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: - """Yield (section name, text) pairs by splitting the whole docstring.""" + """Yield (section name, text) pairs by splitting a docstring.""" prev_name, prev_text = "", "" for section in _split_sections(doc, style): if not section: @@ -208,10 +212,10 @@ def split_without_name(text: str, style: Style) -> tuple[str, str]: def iter_sections(doc: str, style: Style) -> Iterator[Section]: - """Yield [Section] instance by splitting the whole docstring.""" + """Yield [Section] instances by splitting a docstring.""" for name, text in _iter_sections(doc, style): type_ = text_ = "" - items: list[Item] = [] + items = [] if name in ["Parameters", "Attributes", "Raises"]: items = list(iter_items(text, style, name)) elif name in ["Returns", "Yields"]: @@ -224,7 +228,9 @@ def iter_sections(doc: str, style: Style) -> Iterator[Section]: @dataclass(repr=False) -class Docstring(Item): # noqa: D101 +class Docstring(Item): + """Docstring class.""" + sections: list[Section] def __repr__(self) -> str: @@ -233,20 +239,21 @@ def __repr__(self) -> str: def __iter__(self) -> Iterator[Section]: return iter(self.sections) - def get(self, name: str) -> Section | None: # noqa: D102 + def get(self, name: str) -> Section | None: + """Return a [Section] by name.""" return get_by_name(self.sections, name) def parse(doc: str, style: Style | None = None) -> Docstring: """Return a [Docstring] instance.""" - style = style or get_style(doc) doc = add_fence(doc) + style = style or get_style(doc) sections = list(iter_sections(doc, style)) return Docstring("", "", "", sections) def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: - """Yield merged [Item] instances from two list of [Item].""" + """Yield merged [Item] instances from two lists of [Item].""" for name in unique_names(a, b): ai, bi = get_by_name(a, name), get_by_name(b, name) if ai and not bi: @@ -296,7 +303,7 @@ def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: sections.extend(iter_merge_sections(a.sections, b.sections)) is_named_section = False for section in a.sections: - if section.name: # already collected then skip. + if section.name: # already collected, so skip. is_named_section = True elif is_named_section: sections.append(section) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index b211fa90..2f750e0c 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -392,19 +392,16 @@ def __post_init__(self) -> None: modules[self.name] = self def get_fullname(self, name: str | None = None) -> str | None: - """Return the fullname of the module. - - If the name is given, the fullname of member is returned, - possibly with an attribute. - """ + """Return the fullname of the module.""" if not name: return self.name if obj := self.get(name): return obj.fullname if "." in name: name, attr = name.rsplit(".", maxsplit=1) - if obj := self.get(name): - return f"{obj.fullname}.{attr}" + if import_ := self.get_import(name): # noqa: SIM102 + if module := get_module(import_.fullname): + return module.get_fullname(attr) return None def get_source(self, maxline: int | None = None) -> str | None: diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index bdd04fbb..d88e014b 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -55,15 +55,18 @@ def find_submodulenames( return names -def iter_parent_modulenames(fullname: str) -> Iterator[str]: +def iter_parent_modulenames(fullname: str, *, reverse: bool = False) -> Iterator[str]: """Yield parent module names. Examples: >>> list(iter_parent_modulenames("a.b.c.d")) ['a', 'a.b', 'a.b.c', 'a.b.c.d'] + >>> list(iter_parent_modulenames("a.b.c.d", reverse=True)) + ['a.b.c.d', 'a.b.c', 'a.b', 'a'] """ names = fullname.split(".") - for k in range(1, len(names) + 1): + it = range(len(names), 0, -1) if reverse else range(1, len(names) + 1) + for k in it: yield ".".join(names[:k]) diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index f639c3bc..3a1dfe11 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -49,14 +49,15 @@ def test_inherit_other_module2(): def test_inherit_other_module3(): - m = get_module("mkdocs.plugins") - assert m - assert m.get_fullname("TemplateContext") == "mkdocs.utils.templates.TemplateContext" - assert m.get_fullname("jinja2") == "jinja2" + m1 = get_module("mkdocs.plugins") + assert m1 + a = "mkdocs.utils.templates.TemplateContext" + assert m1.get_fullname("TemplateContext") == a + assert m1.get_fullname("jinja2") == "jinja2" a = "jinja2.environment.Template" + assert m1.get_fullname("jinja2.Template") == a m2 = get_module("jinja2") assert m2 - t = m2.get("Template") - assert t - assert t.fullname == a # broken - assert m.get_fullname("jinja2.Template") == a + x = m2.get("Template") + assert x + assert x.fullname == a From d1853dd9a5b3c12e17341c0803dc67b06503076e Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 11 Jan 2024 20:48:33 +0900 Subject: [PATCH 081/148] load_module --- src/mkapi/converter.py | 4 +-- src/mkapi/docstrings.py | 8 ++++-- src/mkapi/objects.py | 43 ++++++++++++++-------------- src_old/mkapi/core/code.py | 8 +++--- src_old/mkapi/core/module.py | 4 +-- tests/conftest.py | 6 ++-- tests/dataclasses/test_deco.py | 4 +-- tests/docstrings/conftest.py | 6 ++-- tests/nodes/test_node.py | 2 +- tests/objects/test_deco.py | 11 ------- tests/objects/test_import.py | 4 +-- tests/objects/test_inherit.py | 6 ++-- tests/objects/test_iter.py | 6 ++-- tests/objects/test_merge.py | 4 +-- tests/objects/test_object.py | 38 ++++++++++++------------ tests/test_renderer.py | 4 +-- tests_old/core/test_core_module.py | 12 ++++---- tests_old/core/test_core_node.py | 4 +-- tests_old/core/test_core_node_abc.py | 2 +- tests_old/core/test_core_object.py | 2 +- 20 files changed, 84 insertions(+), 94 deletions(-) delete mode 100644 tests/objects/test_deco.py diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py index d4a6b345..f4edb976 100644 --- a/src/mkapi/converter.py +++ b/src/mkapi/converter.py @@ -1,14 +1,14 @@ """Converter.""" from __future__ import annotations -from mkapi.objects import get_module +from mkapi.objects import load_module # from mkapi.renderer import renderer def convert_module(name: str, filters: list[str]) -> str: """Convert the [Module] instance to markdown text.""" - if module := get_module(name): + if module := load_module(name): # return renderer.render_module(module) return f"{module}: {id(module)}" return f"{name} not found" diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 6ab5c671..604504ff 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -20,7 +20,9 @@ @dataclass -class Item: # noqa: D101 +class Item: + """Item class for section items.""" + name: str type: str # noqa: A003 text: str @@ -34,7 +36,7 @@ def __repr__(self) -> str: def _iter_items(section: str) -> Iterator[str]: - """Yield items for Parameters, Attributes, and Raises sections.""" + """Yield items for Parameters, Attributes, Returns(?), and Raises sections.""" start = 0 for m in SPLIT_ITEM_PATTERN.finditer(section): yield section[start : m.start()].strip() @@ -147,7 +149,7 @@ def _subsplit(doc: str, style: Style) -> list[str]: def get_style(doc: str) -> Style: - """Return the docstring style of a doc. + """Return the docstring style. If the style can't be determined, the current style is returned. """ diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 2f750e0c..f36cf010 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -12,7 +12,12 @@ import mkapi.ast import mkapi.dataclasses from mkapi import docstrings -from mkapi.utils import del_by_name, get_by_name, iter_parent_modulenames, unique_names +from mkapi.utils import ( + del_by_name, + get_by_name, + iter_parent_modulenames, + unique_names, +) if TYPE_CHECKING: from collections.abc import Iterator @@ -50,7 +55,7 @@ def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: def get_module(self) -> Module | None: """Return a [Module] instance.""" - return get_module(self.modulename) if self.modulename else None + return load_module(self.modulename) if self.modulename else None def get_source(self, maxline: int | None = None) -> str | None: """Return the source code segment.""" @@ -315,7 +320,8 @@ def iter_bases(self) -> Iterator[Class]: @classmethod @contextmanager - def create(cls, node: ast.ClassDef) -> Iterator[Self]: # noqa: D102 + def create(cls, node: ast.ClassDef) -> Iterator[Self]: + """Create a [Class] instance.""" name = node.name args = (name, None, *_callable_args(node)) klass = cls(node, *args, [], [], [], []) @@ -325,7 +331,8 @@ def create(cls, node: ast.ClassDef) -> Iterator[Self]: # noqa: D102 cls.classnames.pop() @classmethod - def get_qualclassname(cls) -> str | None: # noqa: D102 + def get_qualclassname(cls) -> str | None: + """Return current qualified class name.""" return cls.classnames[-1] @@ -338,7 +345,7 @@ def _callable_args( def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: - """Return a [Class] or [Function] instancd.""" + """Return a [Function] instance.""" args = (node.name, None, *_callable_args(node)) return Function(node, *args, get_return(node)) @@ -400,7 +407,7 @@ def get_fullname(self, name: str | None = None) -> str | None: if "." in name: name, attr = name.rsplit(".", maxsplit=1) if import_ := self.get_import(name): # noqa: SIM102 - if module := get_module(import_.fullname): + if module := load_module(import_.fullname): return module.get_fullname(attr) return None @@ -410,10 +417,6 @@ def get_source(self, maxline: int | None = None) -> str | None: return None return "\n".join(self.source.split("\n")[:maxline]) - def get_node_docstring(self) -> str | None: - """Return the docstring of the node.""" - return ast.get_docstring(self._node) - def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: """Yield member instances.""" yield from self.imports @@ -485,7 +488,7 @@ def get_module_path(name: str) -> Path | None: modules: dict[str, Module | None] = {} -def get_module(name: str) -> Module | None: +def load_module(name: str) -> Module | None: """Return a [Module] instance by the name.""" if name in modules: return modules[name] @@ -494,20 +497,20 @@ def get_module(name: str) -> Module | None: return None with path.open("r", encoding="utf-8") as f: source = f.read() - module = get_module_from_source(source, name) + module = load_module_from_source(source, name) module.kind = "package" if path.stem == "__init__" else "module" return module -def get_module_from_source(source: str, name: str = "__mkapi__") -> Module: +def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) - module = get_module_from_node(node, name) + module = load_module_from_node(node, name) module.source = source return module -def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: +def load_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" with Module.create(node, name) as module: for member in iter_members(node): @@ -518,14 +521,10 @@ def get_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: def get_object(fullname: str) -> Module | Class | Function | Attribute | None: """Return a [Object] instance by the fullname.""" - if fullname in modules: - return modules[fullname] if fullname in objects: return objects[fullname] - names = fullname.split(".") - for k in range(1, len(names) + 1): - modulename = ".".join(names[:k]) - if get_module(modulename) and fullname in objects: + for modulename in iter_parent_modulenames(fullname): + if load_module(modulename) and fullname in objects: return objects[fullname] objects[fullname] = None return None @@ -567,7 +566,7 @@ def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: def _merge_docstring(obj: Module | Class | Function) -> None: """Merge [Object] and [Docstring].""" - if not (doc := obj.get_node_docstring()): + if not (doc := ast.get_docstring(obj._node)): # noqa: SLF001 return sections: list[Section] = [] docstring = docstrings.parse(doc) diff --git a/src_old/mkapi/core/code.py b/src_old/mkapi/core/code.py index 8e5e71a2..26c1729f 100644 --- a/src_old/mkapi/core/code.py +++ b/src_old/mkapi/core/code.py @@ -6,7 +6,7 @@ import markdown -from mkapi.core.module import Module, get_module +from mkapi.core.module import Module, load_module @dataclass @@ -20,7 +20,7 @@ class Code: def __post_init__(self): sourcefile = self.module.sourcefile - with io.open(sourcefile, "r", encoding="utf-8-sig", errors="strict") as f: + with open(sourcefile, encoding="utf-8-sig", errors="strict") as f: source = f.read() if not source: return @@ -58,7 +58,7 @@ def get_markdown(self, level: int = 1) -> str: def set_html(self, html: str): pass - def get_html(self, filters: List[str] = None) -> str: + def get_html(self, filters: list[str] = None) -> str: """Renders and returns HTML.""" from mkapi.core.renderer import renderer @@ -85,5 +85,5 @@ def get_code(name: str) -> Code: Args: name: Module name. """ - module = get_module(name) + module = load_module(name) return Code(module) diff --git a/src_old/mkapi/core/module.py b/src_old/mkapi/core/module.py index 8ad03e1c..fcb4e11f 100644 --- a/src_old/mkapi/core/module.py +++ b/src_old/mkapi/core/module.py @@ -83,7 +83,7 @@ def get_members(obj: object) -> list[Module]: name = path[:-3] if name: name = f"{obj.__name__}.{name}" - module = get_module(name) + module = load_module(name) members.append(module) packages = [] modules = [] @@ -98,7 +98,7 @@ def get_members(obj: object) -> list[Module]: modules: dict[str, Module] = {} -def get_module(name: str) -> Module: +def load_module(name: str) -> Module: """Return a Module instace by name or object. Args: diff --git a/tests/conftest.py b/tests/conftest.py index 55dd0fe9..812cd4f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from mkapi.objects import get_module +from mkapi.objects import load_module @pytest.fixture(scope="module") @@ -11,7 +11,7 @@ def google(): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) - return get_module("examples.styles.example_google") + return load_module("examples.styles.example_google") @pytest.fixture(scope="module") @@ -19,4 +19,4 @@ def numpy(): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) - return get_module("examples.styles.example_numpy") + return load_module("examples.styles.example_numpy") diff --git a/tests/dataclasses/test_deco.py b/tests/dataclasses/test_deco.py index b074bfed..769a1a35 100644 --- a/tests/dataclasses/test_deco.py +++ b/tests/dataclasses/test_deco.py @@ -3,7 +3,7 @@ _iter_decorator_args, is_dataclass, ) -from mkapi.objects import get_module_from_source +from mkapi.objects import load_module_from_source source = """ import dataclasses @@ -22,7 +22,7 @@ class B: def test_decorator_arg(): - module = get_module_from_source(source) + module = load_module_from_source(source) cls = module.get_class("A") assert cls assert is_dataclass(cls, module) diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index 5c5e1fd4..efa8ae94 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -8,7 +8,7 @@ from mkapi.objects import get_module_path -def get_module(name): +def load_module(name): path = str(Path(__file__).parent.parent) if path not in sys.path: sys.path.insert(0, str(path)) @@ -21,12 +21,12 @@ def get_module(name): @pytest.fixture(scope="module") def google(): - return get_module("examples.styles.example_google") + return load_module("examples.styles.example_google") @pytest.fixture(scope="module") def numpy(): - return get_module("examples.styles.example_numpy") + return load_module("examples.styles.example_numpy") @pytest.fixture(scope="module") diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py index 5bf613d1..e7b29db8 100644 --- a/tests/nodes/test_node.py +++ b/tests/nodes/test_node.py @@ -8,7 +8,7 @@ # def test_property(): -# module = get_module("mkapi.objects") +# module = load_module("mkapi.objects") # assert module # assert module.id == "mkapi.objects" # f = module.get_function("get_object") diff --git a/tests/objects/test_deco.py b/tests/objects/test_deco.py deleted file mode 100644 index 92690409..00000000 --- a/tests/objects/test_deco.py +++ /dev/null @@ -1,11 +0,0 @@ -import ast - -from mkapi.objects import Class, get_module_from_source - - -def test_deco(): - module = get_module_from_source("@f(x,a=1)\nclass A:\n pass") - cls = module.get("A") - assert isinstance(cls, Class) - deco = cls.decorators[0] - assert isinstance(deco, ast.Call) diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py index fbfb8524..134a98ab 100644 --- a/tests/objects/test_import.py +++ b/tests/objects/test_import.py @@ -4,13 +4,13 @@ # Function, # Import, # Module, -# get_module, +# load_module, # set_import_object, # ) # def test_import(): -# module = get_module("mkapi.plugins") +# module = load_module("mkapi.plugins") # assert module # set_import_object(module) diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 3a1dfe11..9f0da791 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -1,4 +1,4 @@ -from mkapi.objects import Class, get_module, get_object +from mkapi.objects import Class, get_object, load_module from mkapi.utils import get_by_name @@ -49,14 +49,14 @@ def test_inherit_other_module2(): def test_inherit_other_module3(): - m1 = get_module("mkdocs.plugins") + m1 = load_module("mkdocs.plugins") assert m1 a = "mkdocs.utils.templates.TemplateContext" assert m1.get_fullname("TemplateContext") == a assert m1.get_fullname("jinja2") == "jinja2" a = "jinja2.environment.Template" assert m1.get_fullname("jinja2.Template") == a - m2 = get_module("jinja2") + m2 = load_module("jinja2") assert m2 x = m2.get("Template") assert x diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py index 9c25fc25..af4aabf0 100644 --- a/tests/objects/test_iter.py +++ b/tests/objects/test_iter.py @@ -2,12 +2,12 @@ import pytest -from mkapi.objects import Module, Parameter, Return, get_module +from mkapi.objects import Module, Parameter, Return, load_module @pytest.fixture() def module(): - module = get_module("mkapi.objects") + module = load_module("mkapi.objects") assert module return module @@ -28,7 +28,7 @@ def test_func(module: Module): def test_iter_exprs(module: Module): - func = module.get("get_module_from_node") + func = module.get("load_module_from_node") assert func exprs = list(func.iter_exprs()) assert len(exprs) == 4 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index 54b0d182..d790606d 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -9,7 +9,7 @@ Function, Module, Parameter, - get_module, + load_module, modules, ) from mkapi.utils import get_by_name @@ -45,7 +45,7 @@ def module(): name = "examples.styles.example_google" if name in modules: del modules[name] - return get_module(name) + return load_module(name) def test_merge_module_attrs(module: Module): diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 39c8a0bd..3f8f9d0b 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -9,10 +9,9 @@ Class, Function, Module, - get_module, get_module_path, get_object, - iter_imports, + load_module, modules, objects, ) @@ -38,27 +37,27 @@ def test_iter_import_nodes(module: ast.Module): def test_not_found(): - assert get_module("xxx") is None + assert load_module("xxx") is None assert mkapi.objects.modules["xxx"] is None - assert get_module("markdown") + assert load_module("markdown") assert "markdown" in mkapi.objects.modules def test_repr(): - module = get_module("mkapi") + module = load_module("mkapi") assert repr(module) == "Module(mkapi)" - module = get_module("mkapi.objects") + module = load_module("mkapi.objects") assert repr(module) == "Module(mkapi.objects)" obj = get_object("mkapi.objects.Object") assert repr(obj) == "Class(Object)" -def test_get_module_source(): - module = get_module("mkdocs.structure.files") +def test_load_module_source(): + module = load_module("mkdocs.structure.files") assert module assert module.source assert "class File" in module.source - module = get_module("mkapi.plugins") + module = load_module("mkapi.plugins") assert module cls = module.get("MkAPIConfig") assert cls @@ -71,8 +70,8 @@ def test_get_module_source(): assert "MkAPIPlugin" in src -def test_get_module_from_object(): - module = get_module("mkdocs.structure.files") +def test_load_module_from_object(): + module = load_module("mkdocs.structure.files") assert module c = module.classes[1] m = c.get_module() @@ -92,7 +91,7 @@ def test_fullname(google: Module): def test_cache(): modules.clear() objects.clear() - module = get_module("mkapi.objects") + module = load_module("mkapi.objects") c = get_object("mkapi.objects.Object") f = get_object("mkapi.objects.Module.get_class") assert c @@ -104,27 +103,28 @@ def test_cache(): assert c is c2 assert f is f2 - m1 = get_module("mkdocs.structure.files") - m2 = get_module("mkdocs.structure.files") + m1 = load_module("mkdocs.structure.files") + m2 = load_module("mkdocs.structure.files") assert m1 is m2 modules.clear() - m3 = get_module("mkdocs.structure.files") - m4 = get_module("mkdocs.structure.files") + m3 = load_module("mkdocs.structure.files") + m4 = load_module("mkdocs.structure.files") assert m2 is not m3 assert m3 is m4 def test_module_kind(): - module = get_module("mkapi") + module = load_module("mkapi") assert module assert module.kind == "package" - module = get_module("mkapi.objects") + module = load_module("mkapi.objects") assert module assert module.kind == "module" def test_get_fullname_with_attr(): - module = get_module("mkapi.plugins") + module = load_module("mkapi.plugins") assert module name = module.get_fullname("config_options.Type") assert name == "mkdocs.config.config_options.Type" + assert not module.get_fullname("config_options.A") diff --git a/tests/test_renderer.py b/tests/test_renderer.py index bb206bc0..8e03fb49 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,4 +1,4 @@ -# from mkapi.objects import get_module +# from mkapi.objects import load_module # from mkapi.renderer import renderer @@ -10,7 +10,7 @@ # def test_module_empty_filters(): -# module = get_module("mkapi.core.base") +# module = load_module("mkapi.core.base") # m = renderer.render_module(module).split("\n") # assert m[0] == "# ![mkapi](mkapi.core.base|plain|link|sourcelink)" # assert m[2] == "## ![mkapi](mkapi.core.base.Base||link|sourcelink)" diff --git a/tests_old/core/test_core_module.py b/tests_old/core/test_core_module.py index c2be67ea..2b3a6505 100644 --- a/tests_old/core/test_core_module.py +++ b/tests_old/core/test_core_module.py @@ -1,8 +1,8 @@ -from mkapi.core.module import get_module, modules +from mkapi.core.module import load_module, modules -def test_get_module(): - module = get_module("mkapi") +def test_load_module(): + module = load_module("mkapi") assert module.parent is None assert module.object.markdown == "[mkapi](!mkapi)" assert "core" in module @@ -19,15 +19,15 @@ def test_get_module(): def test_repr(): - module = get_module("mkapi.core.base") + module = load_module("mkapi.core.base") s = "Module('mkapi.core.base', num_sections=2, num_members=6)" assert repr(module) == s -def test_get_module_from_object(): +def test_load_module_from_object(): from mkapi.core import base - assert get_module(base).obj is base + assert load_module(base).obj is base def test_cache(): diff --git a/tests_old/core/test_core_node.py b/tests_old/core/test_core_node.py index a6e48dec..ea93ffed 100644 --- a/tests_old/core/test_core_node.py +++ b/tests_old/core/test_core_node.py @@ -1,4 +1,4 @@ -from mkapi.core.module import get_module +from mkapi.core.module import load_module from mkapi.core.node import Node, get_kind, get_node, get_node_from_module, is_member from mkapi.inspect import attribute from mkapi.inspect.signature import Signature @@ -125,7 +125,7 @@ def __getattr__(self, name): def test_get_node_from_module(): - _ = get_module("mkapi.core") + _ = load_module("mkapi.core") x = get_node("mkapi.core.base.Base.__iter__") y = get_node("mkapi.core.base.Base.__iter__") assert x is not y diff --git a/tests_old/core/test_core_node_abc.py b/tests_old/core/test_core_node_abc.py index 5f5dee6e..7bf69c8b 100644 --- a/tests_old/core/test_core_node_abc.py +++ b/tests_old/core/test_core_node_abc.py @@ -48,7 +48,7 @@ def abstract_readwrite_property(self, val): pass -def test_get_module_sourcefiles(): +def test_load_module_sourcefiles(): files = get_sourcefiles(C) assert len(files) == 1 diff --git a/tests_old/core/test_core_object.py b/tests_old/core/test_core_object.py index bbbb0be5..44812ac0 100644 --- a/tests_old/core/test_core_object.py +++ b/tests_old/core/test_core_object.py @@ -19,7 +19,7 @@ def test_get_origin(): assert org is Node -def test_get_module_sourcefile_and_lineno(): +def test_load_module_sourcefile_and_lineno(): sourcefile, _ = get_sourcefile_and_lineno(Node) assert sourcefile.endswith("node.py") From c727a67bce32f7786569cc9e44e4c20c95866635 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 11 Jan 2024 21:40:15 +0900 Subject: [PATCH 082/148] Delete filter.py --- src/mkapi/filter.py | 43 --------------------- src/mkapi/objects.py | 73 ++++++++++-------------------------- src/mkapi/pages.py | 2 +- src/mkapi/plugins.py | 3 +- src/mkapi/utils.py | 42 +++++++++++++++++++++ tests/inspect/__init__.py | 0 tests/objects/test_import.py | 32 ---------------- tests/objects/test_iter.py | 48 ------------------------ tests/objects/test_link.py | 29 ++++++++++++++ tests/objects/test_object.py | 16 ++++++++ 10 files changed, 108 insertions(+), 180 deletions(-) delete mode 100644 src/mkapi/filter.py delete mode 100644 tests/inspect/__init__.py delete mode 100644 tests/objects/test_import.py delete mode 100644 tests/objects/test_iter.py create mode 100644 tests/objects/test_link.py diff --git a/src/mkapi/filter.py b/src/mkapi/filter.py deleted file mode 100644 index 3e8b4a33..00000000 --- a/src/mkapi/filter.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Filter functions.""" - - -def split_filters(name: str) -> tuple[str, list[str]]: - """Split filters written after `|`s. - - Examples: - >>> split_filters("a.b.c") - ('a.b.c', []) - >>> split_filters("a.b.c|upper|strict") - ('a.b.c', ['upper', 'strict']) - >>> split_filters("|upper|strict") - ('', ['upper', 'strict']) - >>> split_filters("") - ('', []) - """ - index = name.find("|") - if index == -1: - return name, [] - name, filters = name[:index], name[index + 1 :] - return name, filters.split("|") - - -def update_filters(org: list[str], update: list[str]) -> list[str]: - """Update filters. - - Examples: - >>> update_filters(['upper'], ['lower']) - ['lower'] - >>> update_filters(['lower'], ['upper']) - ['upper'] - >>> update_filters(['long'], ['short']) - ['short'] - >>> update_filters(['short'], ['long']) - ['long'] - """ - filters = org + update - for x, y in [["lower", "upper"], ["long", "short"]]: - if x in org and y in update: - del filters[filters.index(x)] - if y in org and x in update: - del filters[filters.index(y)] - return filters diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index f36cf010..d9eeabf9 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -42,17 +42,6 @@ def __post_init__(self) -> None: def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" - def __iter__(self) -> Iterator: - yield from [] - - def iter_exprs(self) -> Iterator[ast.expr | ast.type_param]: - """Yield AST expressions.""" - for obj in self: - if isinstance(obj, Object): - yield from obj.iter_exprs() - else: - yield obj - def get_module(self) -> Module | None: """Return a [Module] instance.""" return load_module(self.modulename) if self.modulename else None @@ -101,11 +90,6 @@ class Parameter(Object): default: ast.expr | None kind: _ParameterKind | None - def __iter__(self) -> Iterator[ast.expr]: - for expr in [self.type, self.default]: - if expr: - yield expr - def iter_parameters( node: ast.FunctionDef | ast.AsyncFunctionDef, @@ -126,10 +110,6 @@ def __repr__(self) -> str: exc = ast.unparse(self.type) if self.type else "" return f"{self.__class__.__name__}({exc})" - def __iter__(self) -> Iterator[ast.expr]: - if self.type: - yield self.type - def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" @@ -145,10 +125,6 @@ class Return(Object): _node: ast.expr | None type: ast.expr | None # # noqa: A003 - def __iter__(self) -> Iterator[ast.expr]: - if self.type: - yield self.type - def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: """Return a [Return] instance.""" @@ -183,13 +159,6 @@ class Attribute(Member): default: ast.expr | None type_params: list[ast.type_param] | None - def __iter__(self) -> Iterator[ast.expr | ast.type_param]: - for expr in [self.type, self.default]: - if expr: - yield expr - if self.type_params: - yield from self.type_params - def get_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: """Return an [Attribute] instance.""" @@ -255,10 +224,6 @@ def get_raise(self, name: str) -> Raise | None: """Return a [Riase] instance by the name.""" return get_by_name(self.raises, name) - def get_node_docstring(self) -> str | None: - """Return the docstring of the node.""" - return ast.get_docstring(self._node) - @dataclass(repr=False) class Function(Callable): @@ -398,25 +363,6 @@ def __post_init__(self) -> None: self.qualname = self.fullname = self.name modules[self.name] = self - def get_fullname(self, name: str | None = None) -> str | None: - """Return the fullname of the module.""" - if not name: - return self.name - if obj := self.get(name): - return obj.fullname - if "." in name: - name, attr = name.rsplit(".", maxsplit=1) - if import_ := self.get_import(name): # noqa: SIM102 - if module := load_module(import_.fullname): - return module.get_fullname(attr) - return None - - def get_source(self, maxline: int | None = None) -> str | None: - """Return the source of the module.""" - if not self.source: - return None - return "\n".join(self.source.split("\n")[:maxline]) - def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: """Yield member instances.""" yield from self.imports @@ -458,6 +404,25 @@ def get_function(self, name: str) -> Function | None: """Return an [Function] instance by the name.""" return get_by_name(self.functions, name) + def get_fullname(self, name: str | None = None) -> str | None: + """Return the fullname of the module.""" + if not name: + return self.name + if obj := self.get(name): + return obj.fullname + if "." in name: + name, attr = name.rsplit(".", maxsplit=1) + if import_ := self.get_import(name): # noqa: SIM102 + if module := load_module(import_.fullname): + return module.get_fullname(attr) + return None + + def get_source(self, maxline: int | None = None) -> str | None: + """Return the source of the module.""" + if not self.source: + return None + return "\n".join(self.source.split("\n")[:maxline]) + @classmethod @contextmanager def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index 4187d079..d87d0c9e 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING from mkapi.converter import convert_html, convert_object -from mkapi.filter import split_filters, update_filters from mkapi.link import resolve_link +from mkapi.utils import split_filters, update_filters # from mkapi.core import postprocess # from mkapi.core.base import Base, Section diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index fd617eb5..f62783c6 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -24,8 +24,7 @@ # from mkdocs.utils.templates import TemplateContext import mkapi from mkapi import converter -from mkapi.filter import split_filters, update_filters -from mkapi.utils import find_submodulenames, is_package +from mkapi.utils import find_submodulenames, is_package, split_filters, update_filters if TYPE_CHECKING: from collections.abc import Callable diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index d88e014b..f2258223 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -194,3 +194,45 @@ def unique_names(a: Iterable, b: Iterable, attr: str = "name") -> list[str]: # if (name := getattr(x, attr)) not in names: names.append(name) return names + + +def split_filters(name: str) -> tuple[str, list[str]]: + """Split filters written after `|`s. + + Examples: + >>> split_filters("a.b.c") + ('a.b.c', []) + >>> split_filters("a.b.c|upper|strict") + ('a.b.c', ['upper', 'strict']) + >>> split_filters("|upper|strict") + ('', ['upper', 'strict']) + >>> split_filters("") + ('', []) + """ + index = name.find("|") + if index == -1: + return name, [] + name, filters = name[:index], name[index + 1 :] + return name, filters.split("|") + + +def update_filters(org: list[str], update: list[str]) -> list[str]: + """Update filters. + + Examples: + >>> update_filters(['upper'], ['lower']) + ['lower'] + >>> update_filters(['lower'], ['upper']) + ['upper'] + >>> update_filters(['long'], ['short']) + ['short'] + >>> update_filters(['short'], ['long']) + ['long'] + """ + filters = org + update + for x, y in [["lower", "upper"], ["long", "short"]]: + if x in org and y in update: + del filters[filters.index(x)] + if y in org and x in update: + del filters[filters.index(y)] + return filters diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/objects/test_import.py b/tests/objects/test_import.py deleted file mode 100644 index 134a98ab..00000000 --- a/tests/objects/test_import.py +++ /dev/null @@ -1,32 +0,0 @@ -# from mkapi.objects import ( -# Attribute, -# Class, -# Function, -# Import, -# Module, -# load_module, -# set_import_object, -# ) - - -# def test_import(): -# module = load_module("mkapi.plugins") -# assert module -# set_import_object(module) - -# i = module.get("annotations") -# assert isinstance(i, Import) -# assert isinstance(i.object, Attribute) -# i = module.get("importlib") -# assert isinstance(i, Import) -# assert isinstance(i.object, Module) -# i = module.get("Path") -# assert isinstance(i, Import) -# assert isinstance(i.object, Class) -# i = module.get("get_files") -# assert isinstance(i, Import) -# assert isinstance(i.object, Function) - - -# for x in module.imports: -# print(x.name, x.fullname, x.object) diff --git a/tests/objects/test_iter.py b/tests/objects/test_iter.py deleted file mode 100644 index af4aabf0..00000000 --- a/tests/objects/test_iter.py +++ /dev/null @@ -1,48 +0,0 @@ -import ast - -import pytest - -from mkapi.objects import Module, Parameter, Return, load_module - - -@pytest.fixture() -def module(): - module = load_module("mkapi.objects") - assert module - return module - - -def test_iter(module: Module): - names = [o.name for o in module] - assert "modules" in names - assert "Class" in names - assert "get_object" in names - - -def test_func(module: Module): - func = module.get("_callable_args") - assert func - objs = list(func) - assert isinstance(objs[0], Parameter) - assert isinstance(objs[1], Return) - - -def test_iter_exprs(module: Module): - func = module.get("load_module_from_node") - assert func - exprs = list(func.iter_exprs()) - assert len(exprs) == 4 - assert ast.unparse(exprs[0]) == "ast.Module" - assert ast.unparse(exprs[1]) == "str" - assert ast.unparse(exprs[2]) == "'__mkapi__'" - assert ast.unparse(exprs[3]) == "Module" - - -def test_iter_bases(module: Module): - cls = module.get_class("Class") - assert cls - bases = cls.iter_bases() - assert next(bases).name == "Object" - assert next(bases).name == "Member" - assert next(bases).name == "Callable" - assert next(bases).name == "Class" diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py new file mode 100644 index 00000000..e2b035d8 --- /dev/null +++ b/tests/objects/test_link.py @@ -0,0 +1,29 @@ +# import mkapi.ast +# from mkapi.objects import Module, Object, load_module +# import ast + +# def convert(obj:Object): + + +# expr:ast.expr|ast.type_param): +# if not module := + + +# def test_expr_mkapi_objects(): +# module = load_module("mkapi.objects") +# assert module + +# def get_callback(module:Module): +# def callback(x: str) -> str: +# fullname = module.get_fullname(x) +# if fullname: +# return f"[{x}][__mkapi__.{fullname}]" +# return x + + +# cls = module.get_class("Class") +# assert cls +# for p in cls.parameters: +# t = mkapi.ast.unparse(p.type, callback) if p.type else "---" +# print(p.name, t) +# assert 0 diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 3f8f9d0b..383c78cb 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -128,3 +128,19 @@ def test_get_fullname_with_attr(): name = module.get_fullname("config_options.Type") assert name == "mkdocs.config.config_options.Type" assert not module.get_fullname("config_options.A") + + +def test_iter(): + module = load_module("mkapi.objects") + assert module + names = [o.name for o in module] + assert "modules" in names + assert "Class" in names + assert "get_object" in names + cls = module.get_class("Class") + assert cls + bases = cls.iter_bases() + assert next(bases).name == "Object" + assert next(bases).name == "Member" + assert next(bases).name == "Callable" + assert next(bases).name == "Class" From cee8796d584bb2630c0d43e3979b79b58f19ae05 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Thu, 11 Jan 2024 22:31:33 +0900 Subject: [PATCH 083/148] before Docstring -> Description --- src/mkapi/dataclasses.py | 4 +-- src/mkapi/objects.py | 51 +++++++++++++++++++---------------- tests/objects/test_inherit.py | 6 ++--- tests/objects/test_link.py | 3 ++- tests/objects/test_object.py | 8 +++--- 5 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index 89b75909..f5fd457d 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -25,14 +25,14 @@ def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: def is_dataclass(cls: Class, module: Module | None = None) -> bool: """Return True if the class is a dataclass.""" - if module := module or cls.get_module(): + if module := module or cls.module: return _get_dataclass_decorator(cls, module) is not None return False def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: """Yield tuples of ([Attribute], [_ParameterKind]) for dataclass signature.""" - if not (modulename := cls.modulename): + if not cls.module or not (modulename := cls.module.name): raise NotImplementedError try: module = importlib.import_module(modulename) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index d9eeabf9..b03c6013 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -33,22 +33,22 @@ class Object: _node: ast.AST name: str - modulename: str | None = field(init=False) + module: Module | None = field(init=False) docstring: str | None def __post_init__(self) -> None: - self.modulename = Module.get_modulename() + self.module = Module.current_module() def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" - def get_module(self) -> Module | None: - """Return a [Module] instance.""" - return load_module(self.modulename) if self.modulename else None + # def get_module(self) -> Module | None: + # """Return a [Module] instance.""" + # return load_module(self.modulename) if self.modulename else None def get_source(self, maxline: int | None = None) -> str | None: """Return the source code segment.""" - if (module := self.get_module()) and (source := module.source): + if (module := self.module) and (source := module.source): start, stop = self._node.lineno - 1, self._node.end_lineno return "\n".join(source.split("\n")[start:stop][:maxline]) return None @@ -82,11 +82,18 @@ def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: @dataclass(repr=False) -class Parameter(Object): +class Type(Object): + """Type class.""" + + type: ast.expr | None # # noqa: A003 + markdown: str = field(init=False) + + +@dataclass(repr=False) +class Parameter(Type): """Parameter class for [Class] and [Function].""" _node: ast.arg | None - type: ast.expr | None # # noqa: A003 default: ast.expr | None kind: _ParameterKind | None @@ -100,11 +107,10 @@ def iter_parameters( @dataclass(repr=False) -class Raise(Object): +class Raise(Type): """Raise class for [Class] and [Function].""" _node: ast.Raise | None - type: ast.expr | None # # noqa: A003 def __repr__(self) -> str: exc = ast.unparse(self.type) if self.type else "" @@ -119,11 +125,10 @@ def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise] @dataclass(repr=False) -class Return(Object): +class Return(Type): """Return class for [Class] and [Function].""" _node: ast.expr | None - type: ast.expr | None # # noqa: A003 def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: @@ -145,17 +150,16 @@ def __post_init__(self) -> None: super().__post_init__() qualname = Class.get_qualclassname() self.qualname = f"{qualname}.{self.name}" if qualname else self.name - m_name = self.modulename + m_name = self.module.name if self.module else "__" self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname objects[self.fullname] = self # type:ignore @dataclass(repr=False) -class Attribute(Member): +class Attribute(Type, Member): """Atrribute class for [Module] and [Class].""" _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None - type: ast.expr | None # # noqa: A003 default: ast.expr | None type_params: list[ast.type_param] | None @@ -356,7 +360,7 @@ class Module(Member): functions: list[Function] source: str | None kind: str | None - modulenames: ClassVar[list[str | None]] = [None] + modules: ClassVar[list[Self | None]] = [None] def __post_init__(self) -> None: super().__post_init__() @@ -427,13 +431,14 @@ def get_source(self, maxline: int | None = None) -> str | None: @contextmanager def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 module = cls(node, name, None, [], [], [], [], None, None) - cls.modulenames.append(name) + cls.modules.append(module) yield module - cls.modulenames.pop() + cls.modules.pop() @classmethod - def get_modulename(cls) -> str | None: # noqa: D102 - return cls.modulenames[-1] + def current_module(cls) -> Self | None: + """Return the current [Module] instance.""" + return cls.modules[-1] def get_module_path(name: str) -> Path | None: @@ -579,11 +584,11 @@ def _iter_base_classes(cls: Class) -> Iterator[Class]: This function is called in postprocess for setting base classes. """ - if not (module := cls.get_module()): + if not cls.module: return for node in cls._node.bases: base_name = next(mkapi.ast.iter_identifiers(node)) - base_fullname = module.get_fullname(base_name) + base_fullname = cls.module.get_fullname(base_name) if not base_fullname: continue base = get_object(base_fullname) @@ -615,5 +620,5 @@ def _postprocess_class(cls: Class) -> None: for attr, kind in mkapi.dataclasses.iter_parameters(cls): args = (None, attr.name, attr.docstring, attr.type, attr.default, kind) parameter = Parameter(*args) - parameter.modulename = attr.modulename + parameter.module = attr.module cls.parameters.append(parameter) diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 9f0da791..ccaec8e8 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -13,14 +13,14 @@ def test_inherit(): assert get_by_name(a, "_node") assert get_by_name(a, "qualname") assert get_by_name(a, "fullname") - assert get_by_name(a, "modulename") + assert get_by_name(a, "module") assert get_by_name(a, "classnames") p = cls.parameters assert len(p) == 11 assert get_by_name(p, "_node") assert not get_by_name(p, "qualname") assert not get_by_name(p, "fullname") - assert not get_by_name(p, "modulename") + assert not get_by_name(p, "module") assert not get_by_name(p, "classnames") @@ -42,7 +42,7 @@ def test_inherit_other_module2(): assert x p = x.parameters[1] assert p.unparse() == "template: jinja2.Template" - m = p.get_module() + m = p.module assert m assert m.name == "mkdocs.plugins" assert m.get("get_plugin_logger") diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index e2b035d8..83b08b62 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,8 +1,9 @@ # import mkapi.ast -# from mkapi.objects import Module, Object, load_module +# from mkapi.objects import Module, Object, load_module,Parameter # import ast # def convert(obj:Object): +# mkapi.ast._iter_identifiers # expr:ast.expr|ast.type_param): diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 383c78cb..dd3f79e7 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -61,7 +61,7 @@ def test_load_module_source(): assert module cls = module.get("MkAPIConfig") assert cls - assert cls.get_module() is module + assert cls.module is module src = cls.get_source() assert src assert src.startswith("class MkAPIConfig") @@ -74,7 +74,7 @@ def test_load_module_from_object(): module = load_module("mkdocs.structure.files") assert module c = module.classes[1] - m = c.get_module() + m = c.module assert module is m @@ -95,9 +95,9 @@ def test_cache(): c = get_object("mkapi.objects.Object") f = get_object("mkapi.objects.Module.get_class") assert c - assert c.get_module() is module + assert c.module is module assert f - assert f.get_module() is module + assert f.module is module c2 = get_object("mkapi.objects.Object") f2 = get_object("mkapi.objects.Module.get_class") assert c is c2 From 06e4de64faec229627bbf5d797de0510fcb8c30d Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 12 Jan 2024 08:36:16 +0900 Subject: [PATCH 084/148] Type, Text classes --- src/mkapi/ast.py | 2 +- src/mkapi/dataclasses.py | 2 +- src/mkapi/objects.py | 269 ++++++++++++++++--------------- tests/dataclasses/test_params.py | 49 +++--- tests/objects/test_arg.py | 40 +++-- tests/objects/test_attr.py | 14 +- tests/objects/test_inherit.py | 10 +- tests/objects/test_link.py | 37 +++-- tests/objects/test_merge.py | 94 ++++++----- 9 files changed, 280 insertions(+), 237 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 481e26e6..0a78d68e 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -153,7 +153,7 @@ def _is_identifier(name: str) -> bool: return name != "" and all(x.isidentifier() for x in _split_name(name)) -def get_expr(name: str) -> ast.expr: +def create_expr(name: str) -> ast.expr: """Return an [ast.expr] instance of a name.""" if _is_identifier(name): expr = ast.parse(name).body[0] diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py index f5fd457d..5ce48366 100644 --- a/src/mkapi/dataclasses.py +++ b/src/mkapi/dataclasses.py @@ -16,7 +16,7 @@ def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: - for deco in cls._node.decorator_list: + for deco in cls.node.decorator_list: name = next(iter_identifiers(deco)) if module.get_fullname(name) == "dataclasses.dataclass": return deco diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index b03c6013..69470ae7 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,7 +5,7 @@ import importlib import importlib.util from contextlib import contextmanager -from dataclasses import dataclass, field +from dataclasses import InitVar, dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -27,78 +27,64 @@ from mkapi.docstrings import Docstring, Item, Section +@dataclass +class Type: + """Type class.""" + + expr: ast.expr + markdown: str = field(default="", init=False) + + +@dataclass +class Text: + """Text class.""" + + str: str # noqa: A003 + markdown: str = field(default="", init=False) + + @dataclass class Object: """Object base class.""" - _node: ast.AST + node: ast.AST name: str module: Module | None = field(init=False) - docstring: str | None + text: Text | None = field(init=False) + type: Type | None = field(init=False) # noqa: A003 + _text: InitVar[str | None] + _type: InitVar[ast.expr | None] - def __post_init__(self) -> None: - self.module = Module.current_module() + def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: + self.module = Module.current + self.type = Type(_type) if _type else None + self.text = Text(_text) if _text else None def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" - # def get_module(self) -> Module | None: - # """Return a [Module] instance.""" - # return load_module(self.modulename) if self.modulename else None - def get_source(self, maxline: int | None = None) -> str | None: """Return the source code segment.""" if (module := self.module) and (source := module.source): - start, stop = self._node.lineno - 1, self._node.end_lineno + start, stop = self.node.lineno - 1, self.node.end_lineno return "\n".join(source.split("\n")[start:stop][:maxline]) return None def unparse(self) -> str: """Unparse the AST node and return a string expression.""" - return ast.unparse(self._node) + return ast.unparse(self.node) @dataclass(repr=False) -class Import(Object): - """Import class for [Module].""" - - _node: ast.Import | ast.ImportFrom - fullname: str - from_: str | None - level: int - - -def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: - """Yield [Import] instances.""" - from_ = f"{node.module}" if isinstance(node, ast.ImportFrom) else None - for alias in node.names: - name = alias.asname or alias.name - fullname = f"{from_}.{alias.name}" if from_ else name - if isinstance(node, ast.Import): - for parent in iter_parent_modulenames(fullname): - yield Import(node, parent, None, parent, None, 0) - else: - yield Import(node, name, None, fullname, from_, node.level) - - -@dataclass(repr=False) -class Type(Object): - """Type class.""" - - type: ast.expr | None # # noqa: A003 - markdown: str = field(init=False) - - -@dataclass(repr=False) -class Parameter(Type): +class Parameter(Object): """Parameter class for [Class] and [Function].""" - _node: ast.arg | None + node: ast.arg | None default: ast.expr | None kind: _ParameterKind | None -def iter_parameters( +def create_parameters( node: ast.FunctionDef | ast.AsyncFunctionDef, ) -> Iterator[Parameter]: """Yield parameters from the function node.""" @@ -107,17 +93,17 @@ def iter_parameters( @dataclass(repr=False) -class Raise(Type): +class Raise(Object): """Raise class for [Class] and [Function].""" - _node: ast.Raise | None + node: ast.Raise | None def __repr__(self) -> str: - exc = ast.unparse(self.type) if self.type else "" + exc = ast.unparse(self.type.expr) if self.type else "" return f"{self.__class__.__name__}({exc})" -def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: +def create_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" for child in ast.walk(node): if isinstance(child, ast.Raise) and child.exc: @@ -125,17 +111,45 @@ def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise] @dataclass(repr=False) -class Return(Type): +class Return(Object): """Return class for [Class] and [Function].""" - _node: ast.expr | None + node: ast.expr | None -def get_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: +def create_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: """Return a [Return] instance.""" return Return(node.returns, "", None, node.returns) +@dataclass(repr=False) +class Import: + """Import class for [Module].""" + + node: ast.Import | ast.ImportFrom + name: str + fullname: str + from_: str | None + level: int + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name})" + + +def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: + """Yield [Import] instances.""" + for alias in node.names: + name = alias.asname or alias.name + if isinstance(node, ast.Import): + for parent in iter_parent_modulenames(name): + # TODO: ex. import matplotlib.pyplot as plt + yield Import(node, parent, parent, None, 0) + else: + from_ = f"{node.module}" + fullname = f"{from_}.{alias.name}" + yield Import(node, name, fullname, from_, node.level) + + objects: dict[str, Attribute | Class | Function | Module | None] = {} @@ -146,70 +160,67 @@ class Member(Object): qualname: str = field(init=False) fullname: str = field(init=False) - def __post_init__(self) -> None: - super().__post_init__() + def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: + super().__post_init__(_text, _type) qualname = Class.get_qualclassname() self.qualname = f"{qualname}.{self.name}" if qualname else self.name - m_name = self.module.name if self.module else "__" + m_name = self.module.name if self.module else "__mkapi__" self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname objects[self.fullname] = self # type:ignore @dataclass(repr=False) -class Attribute(Type, Member): +class Attribute(Member): """Atrribute class for [Module] and [Class].""" - _node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None + node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None default: ast.expr | None - type_params: list[ast.type_param] | None + # type_params: list[ast.type_param] | None -def get_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: +def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: """Return an [Attribute] instance.""" name = mkapi.ast.get_assign_name(node) or "" - doc = node.__doc__ type_ = mkapi.ast.get_assign_type(node) - doc, type_ = _get_attribute_doc_type(doc, type_) + text, type_ = _attribute_text_type(node.__doc__, type_) default = None if isinstance(node, ast.TypeAlias) else node.value - type_params = node.type_params if isinstance(node, ast.TypeAlias) else None - return Attribute(node, name, doc, type_, default, type_params) + return Attribute(node, name, text, type_, default) + # type_params = node.type_params if isinstance(node, ast.TypeAlias) else None + # return Attribute(node, name, text, type_, default, type_params) -def get_attribute_from_property(node: ast.FunctionDef) -> Attribute: +def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: """Return an [Attribute] instance from a property.""" - name = node.name doc = ast.get_docstring(node) type_ = node.returns - doc, type_ = _get_attribute_doc_type(doc, type_) - default = None - type_params = node.type_params - return Attribute(node, name, doc, type_, default, type_params) + text, type_ = _attribute_text_type(doc, type_) + return Attribute(node, node.name, text, type_, None) + # default = None + # type_params = node.type_params + # return Attribute(node, name, text, type_, default, type_params) -def _get_attribute_doc_type( +def _attribute_text_type( doc: str | None, type_: ast.expr | None, ) -> tuple[str | None, ast.expr | None]: if not doc: return doc, type_ - type_doc, doc = docstrings.split_without_name(doc, "google") + type_doc, text = docstrings.split_without_name(doc, "google") if not type_ and type_doc: # ex. list(str) -> list[str] type_doc = type_doc.replace("(", "[").replace(")", "]") - type_ = mkapi.ast.get_expr(type_doc) - return doc, type_ + type_ = mkapi.ast.create_expr(type_doc) + return text, type_ @dataclass(repr=False) class Callable(Member): """Callable class for [Class] and [Function].""" - _node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef - docstring: Docstring | None + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef parameters: list[Parameter] raises: list[Raise] - decorators: list[ast.expr] - type_params: list[ast.type_param] def __iter__(self) -> Iterator[Parameter | Raise]: """Yield member instances.""" @@ -229,11 +240,22 @@ def get_raise(self, name: str) -> Raise | None: return get_by_name(self.raises, name) +def _callable_args( + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[str | None, None, list[Parameter], list[Raise]]: + text = ast.get_docstring(node) + if isinstance(node, ast.ClassDef): + return text, None, [], [] + parameters = list(create_parameters(node)) + raises = list(create_raises(node)) + return text, None, parameters, raises + + @dataclass(repr=False) class Function(Callable): """Function class.""" - _node: ast.FunctionDef | ast.AsyncFunctionDef + node: ast.FunctionDef | ast.AsyncFunctionDef returns: Return def __iter__(self) -> Iterator[Parameter | Raise | Return]: @@ -242,11 +264,16 @@ def __iter__(self) -> Iterator[Parameter | Raise | Return]: yield self.returns +def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: + """Return a [Function] instance.""" + return Function(node, node.name, *_callable_args(node), create_return(node)) + + @dataclass(repr=False) class Class(Callable): """Class class.""" - _node: ast.ClassDef + node: ast.ClassDef attributes: list[Attribute] classes: list[Class] functions: list[Function] @@ -292,8 +319,7 @@ def iter_bases(self) -> Iterator[Class]: def create(cls, node: ast.ClassDef) -> Iterator[Self]: """Create a [Class] instance.""" name = node.name - args = (name, None, *_callable_args(node)) - klass = cls(node, *args, [], [], [], []) + klass = cls(node, node.name, *_callable_args(node), [], [], [], []) qualname = f"{cls.classnames[-1]}.{name}" if cls.classnames[-1] else name cls.classnames.append(qualname) yield klass @@ -305,45 +331,31 @@ def get_qualclassname(cls) -> str | None: return cls.classnames[-1] -def _callable_args( - node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, -) -> tuple[list[Parameter], list[Raise], list[ast.expr], list[ast.type_param]]: - parameters = [] if isinstance(node, ast.ClassDef) else list(iter_parameters(node)) - raises = [] if isinstance(node, ast.ClassDef) else list(iter_raises(node)) - return parameters, raises, node.decorator_list, node.type_params - - -def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: - """Return a [Function] instance.""" - args = (node.name, None, *_callable_args(node)) - return Function(node, *args, get_return(node)) - - -def get_class(node: ast.ClassDef) -> Class: +def create_class(node: ast.ClassDef) -> Class: """Return a [Class] instance.""" with Class.create(node) as cls: - for member in iter_members(node): + for member in create_members(node): cls.add_member(member) return cls -def iter_members( +def create_members( node: ast.ClassDef | ast.Module, ) -> Iterator[Import | Attribute | Class | Function]: """Yield members.""" for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.FunctionDef): # noqa: SIM102 if mkapi.ast.is_property(child.decorator_list): - yield get_attribute_from_property(child) + yield create_attribute_from_property(child) continue if isinstance(child, ast.Import | ast.ImportFrom): yield from iter_imports(child) elif isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): - attr = get_attribute(child) + attr = create_attribute(child) if attr.name: yield attr elif isinstance(child, ast.ClassDef): - yield get_class(child) + yield create_class(child) elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): yield get_function(child) @@ -352,18 +364,17 @@ def iter_members( class Module(Member): """Module class.""" - _node: ast.Module - docstring: Docstring | None + node: ast.Module imports: list[Import] attributes: list[Attribute] classes: list[Class] functions: list[Function] source: str | None kind: str | None - modules: ClassVar[list[Self | None]] = [None] + current: ClassVar[Self | None] = None - def __post_init__(self) -> None: - super().__post_init__() + def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: + super().__post_init__(_text, _type) self.qualname = self.fullname = self.name modules[self.name] = self @@ -430,15 +441,11 @@ def get_source(self, maxline: int | None = None) -> str | None: @classmethod @contextmanager def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 - module = cls(node, name, None, [], [], [], [], None, None) - cls.modules.append(module) + text = ast.get_docstring(node) + module = cls(node, name, text, None, [], [], [], [], None, None) + cls.current = module yield module - cls.modules.pop() - - @classmethod - def current_module(cls) -> Self | None: - """Return the current [Module] instance.""" - return cls.modules[-1] + cls.current = None def get_module_path(name: str) -> Path | None: @@ -483,7 +490,7 @@ def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: def load_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" with Module.create(node, name) as module: - for member in iter_members(node): + for member in create_members(node): module.add_member(member) _postprocess(module) return module @@ -504,20 +511,21 @@ def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None if not obj.type and item.type: # ex. list(str) -> list[str] type_ = item.type.replace("(", "[").replace(")", "]") - obj.type = mkapi.ast.get_expr(type_) - obj.docstring = item.text # Does item.text win? + obj.type = Type(mkapi.ast.create_expr(type_)) + obj.text = Text(item.text) # Does item.text win? def _new( cls: type[Attribute | Parameter | Raise], name: str, ) -> Attribute | Parameter | Raise: + args = (None, name, None, None) if cls is Attribute: - return Attribute(None, name, None, None, None, []) + return Attribute(*args, None) if cls is Parameter: - return Parameter(None, name, None, None, None, None) + return Parameter(*args, None, None) if cls is Raise: - return Raise(None, name, None, None) + return Raise(*args) raise NotImplementedError @@ -536,11 +544,10 @@ def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: def _merge_docstring(obj: Module | Class | Function) -> None: """Merge [Object] and [Docstring].""" - if not (doc := ast.get_docstring(obj._node)): # noqa: SLF001 + if not obj.text: return sections: list[Section] = [] - docstring = docstrings.parse(doc) - for section in docstring: + for section in docstrings.parse(obj.text.str): if section.name == "Attributes" and isinstance(obj, Module | Class): obj.attributes = _merge_items(Attribute, obj.attributes, section.items) elif section.name == "Parameters" and isinstance(obj, Class | Function): @@ -552,8 +559,7 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.returns.name = section.name else: sections.append(section) - docstring.sections = sections - obj.docstring = docstring + obj.text.str = "xxx" # TODO: sections -> text string def _postprocess(obj: Module | Class) -> None: @@ -574,9 +580,9 @@ def _postprocess(obj: Module | Class) -> None: def _attribute_order(attr: Attribute) -> int: - if not (node := attr._node): # noqa: SLF001 + if not attr.node: return 0 - return ATTRIBUTE_ORDER_DICT.get(type(node), 10) + return ATTRIBUTE_ORDER_DICT.get(type(attr.node), 10) def _iter_base_classes(cls: Class) -> Iterator[Class]: @@ -586,7 +592,7 @@ def _iter_base_classes(cls: Class) -> Iterator[Class]: """ if not cls.module: return - for node in cls._node.bases: + for node in cls.node.bases: base_name = next(mkapi.ast.iter_identifiers(node)) base_fullname = cls.module.get_fullname(base_name) if not base_fullname: @@ -597,6 +603,7 @@ def _iter_base_classes(cls: Class) -> Iterator[Class]: def _inherit(cls: Class, name: str) -> None: + # TODO: fix InitVar, ClassVar for dataclasses. members = {} for base in cls.bases: for member in getattr(base, name): @@ -613,12 +620,14 @@ def _postprocess_class(cls: Class) -> None: if init := cls.get_function("__init__"): cls.parameters = init.parameters cls.raises = init.raises - cls.docstring = docstrings.merge(cls.docstring, init.docstring) + # cls.docstring = docstrings.merge(cls.docstring, init.docstring) cls.attributes.sort(key=_attribute_order) del_by_name(cls.functions, "__init__") if mkapi.dataclasses.is_dataclass(cls): for attr, kind in mkapi.dataclasses.iter_parameters(cls): - args = (None, attr.name, attr.docstring, attr.type, attr.default, kind) + args = (None, attr.name, None, None, attr.default, kind) parameter = Parameter(*args) + parameter.text = attr.text + parameter.type = attr.type parameter.module = attr.module cls.parameters.append(parameter) diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index ae11a925..3bc9b489 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -1,7 +1,7 @@ import ast from mkapi.dataclasses import is_dataclass -from mkapi.objects import Class, get_object +from mkapi.objects import Class, Parameter, get_object def test_parameters(): @@ -9,37 +9,38 @@ def test_parameters(): assert isinstance(cls, Class) assert is_dataclass(cls) p = cls.parameters - assert len(p) == 11 - assert p[0].name == "_node" + assert len(p) == 10 + assert p[0].name == "node" assert p[0].type - assert ast.unparse(p[0].type) == "ast.ClassDef" + assert ast.unparse(p[0].type.expr) == "ast.ClassDef" assert p[1].name == "name" assert p[1].type - assert ast.unparse(p[1].type) == "str" - assert p[2].name == "docstring" + assert ast.unparse(p[1].type.expr) == "str" + assert p[2].name == "_text" assert p[2].type - assert ast.unparse(p[2].type) == "Docstring | None" - assert p[3].name == "parameters" + assert ( + ast.unparse(p[2].type.expr) == "InitVar[str | None]" + ) # TODO: Delete `InitVar` + assert p[3].name == "_type" assert p[3].type - assert ast.unparse(p[3].type) == "list[Parameter]" - assert p[4].name == "raises" + assert ( + ast.unparse(p[3].type.expr) == "InitVar[ast.expr | None]" + ) # TODO: Delete `InitVar` + assert p[4].name == "parameters" assert p[4].type - assert ast.unparse(p[4].type) == "list[Raise]" - assert p[5].name == "decorators" + assert ast.unparse(p[4].type.expr) == "list[Parameter]" + assert p[5].name == "raises" assert p[5].type - assert ast.unparse(p[5].type) == "list[ast.expr]" - assert p[6].name == "type_params" + assert ast.unparse(p[5].type.expr) == "list[Raise]" + assert p[6].name == "attributes" assert p[6].type - assert ast.unparse(p[6].type) == "list[ast.type_param]" - assert p[7].name == "attributes" + assert ast.unparse(p[6].type.expr) == "list[Attribute]" + assert p[7].name == "classes" assert p[7].type - assert ast.unparse(p[7].type) == "list[Attribute]" - assert p[8].name == "classes" + assert ast.unparse(p[7].type.expr) == "list[Class]" + assert p[8].name == "functions" assert p[8].type - assert ast.unparse(p[8].type) == "list[Class]" - assert p[9].name == "functions" + assert ast.unparse(p[8].type.expr) == "list[Function]" + assert p[9].name == "bases" assert p[9].type - assert ast.unparse(p[9].type) == "list[Function]" - assert p[10].name == "bases" - assert p[10].type - assert ast.unparse(p[10].type) == "list[Class]" + assert ast.unparse(p[9].type.expr) == "list[Class]" diff --git a/tests/objects/test_arg.py b/tests/objects/test_arg.py index a81bfbb4..c094d209 100644 --- a/tests/objects/test_arg.py +++ b/tests/objects/test_arg.py @@ -1,13 +1,13 @@ import ast from inspect import Parameter -from mkapi.objects import iter_parameters +from mkapi.objects import create_parameters def _get_args(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.FunctionDef) - return list(iter_parameters(node)) + return list(create_parameters(node)) def test_get_parameters_1(): @@ -20,22 +20,26 @@ def test_get_parameters_1(): x = _get_args("def f(x=1):\n pass")[0] assert isinstance(x.default, ast.Constant) x = _get_args("def f(x:str='s'):\n pass")[0] - assert isinstance(x.type, ast.Name) - assert x.type.id == "str" + assert x.type + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "str" assert isinstance(x.default, ast.Constant) assert x.default.value == "s" x = _get_args("def f(x:'X'='s'):\n pass")[0] - assert isinstance(x.type, ast.Constant) - assert x.type.value == "X" + assert x.type + assert isinstance(x.type.expr, ast.Constant) + assert x.type.expr.value == "X" def test_get_parameters_2(): x = _get_args("def f(x:tuple[int]=(1,)):\n pass")[0] - assert isinstance(x.type, ast.Subscript) - assert isinstance(x.type.value, ast.Name) - assert x.type.value.id == "tuple" - assert isinstance(x.type.slice, ast.Name) - assert x.type.slice.id == "int" + assert x.type + node = x.type.expr + assert isinstance(node, ast.Subscript) + assert isinstance(node.value, ast.Name) + assert node.value.id == "tuple" + assert isinstance(node.slice, ast.Name) + assert node.slice.id == "int" assert isinstance(x.default, ast.Tuple) assert isinstance(x.default.elts[0], ast.Constant) assert x.default.elts[0].value == 1 @@ -43,10 +47,12 @@ def test_get_parameters_2(): def test_get_parameters_3(): x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] - assert isinstance(x.type, ast.Subscript) - assert isinstance(x.type.value, ast.Name) - assert x.type.value.id == "tuple" - assert isinstance(x.type.slice, ast.Tuple) - assert x.type.slice.elts[0].id == "int" # type: ignore - assert x.type.slice.elts[1].id == "str" # type: ignore + assert x.type + node = x.type.expr + assert isinstance(node, ast.Subscript) + assert isinstance(node.value, ast.Name) + assert node.value.id == "tuple" + assert isinstance(node.slice, ast.Tuple) + assert node.slice.elts[0].id == "int" # type: ignore + assert node.slice.elts[1].id == "str" # type: ignore assert isinstance(x.default, ast.Tuple) diff --git a/tests/objects/test_attr.py b/tests/objects/test_attr.py index dfc53938..3367d27f 100644 --- a/tests/objects/test_attr.py +++ b/tests/objects/test_attr.py @@ -1,12 +1,12 @@ import ast -from mkapi.objects import Attribute, iter_members +from mkapi.objects import Attribute, create_members def _get_attributes(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.ClassDef) - return list(iter_members(node)) + return list(create_members(node)) def test_get_attributes(): @@ -16,16 +16,18 @@ def test_get_attributes(): assert x.type is None assert isinstance(x.default, ast.Call) assert ast.unparse(x.default.func) == "f.g" - assert x.docstring == "docstring" + assert x.text + assert x.text.str == "docstring" src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" assigns = _get_attributes(src) x, y, z = assigns assert isinstance(x, Attribute) assert isinstance(y, Attribute) assert isinstance(z, Attribute) - assert x.docstring is None + assert not x.text assert x.default is None - assert y.docstring == "docstring\na" - assert z.docstring is None + assert y.text + assert y.text.str == "docstring\na" + assert not z.text assert z.default is not None assert list(assigns) == [x, y, z] diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index ccaec8e8..26d5b397 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -9,15 +9,19 @@ def test_inherit(): assert cls.bases[0].name == "Callable" assert cls.bases[0].fullname == "mkapi.objects.Callable" a = cls.attributes - assert len(a) == 15 - assert get_by_name(a, "_node") + for x in a: + print(x) + assert 0 + # TODO: fix InitVar, ClassVar + assert len(a) == 14 + assert get_by_name(a, "node") assert get_by_name(a, "qualname") assert get_by_name(a, "fullname") assert get_by_name(a, "module") assert get_by_name(a, "classnames") p = cls.parameters assert len(p) == 11 - assert get_by_name(p, "_node") + assert get_by_name(p, "node") assert not get_by_name(p, "qualname") assert not get_by_name(p, "fullname") assert not get_by_name(p, "module") diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 83b08b62..64cb2746 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,6 +1,7 @@ -# import mkapi.ast -# from mkapi.objects import Module, Object, load_module,Parameter -# import ast +import ast + +import mkapi.ast +from mkapi.objects import Module, Object, Parameter, load_module # def convert(obj:Object): # mkapi.ast._iter_identifiers @@ -10,21 +11,19 @@ # if not module := -# def test_expr_mkapi_objects(): -# module = load_module("mkapi.objects") -# assert module - -# def get_callback(module:Module): -# def callback(x: str) -> str: -# fullname = module.get_fullname(x) -# if fullname: -# return f"[{x}][__mkapi__.{fullname}]" -# return x +def test_expr_mkapi_objects(): + module = load_module("mkapi.objects") + assert module + def callback(x: str) -> str: + fullname = module.get_fullname(x) + if fullname: + return f"[{x}][__mkapi__.{fullname}]" + return x -# cls = module.get_class("Class") -# assert cls -# for p in cls.parameters: -# t = mkapi.ast.unparse(p.type, callback) if p.type else "---" -# print(p.name, t) -# assert 0 + cls = module.get_class("Class") + assert cls + for p in cls.parameters: + t = mkapi.ast.unparse(p.type, callback) if p.type else "---" + print(p.name, t) + assert 0 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index d790606d..982e9302 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -2,7 +2,6 @@ import pytest -from mkapi.docstrings import Docstring from mkapi.objects import ( Attribute, Class, @@ -19,25 +18,30 @@ def test_split_attribute_docstring(google): name = "module_level_variable2" node = google.get(name) assert isinstance(node, Attribute) - assert isinstance(node.docstring, str) - assert node.docstring.startswith("Module level") - assert node.docstring.endswith("by a colon.") - assert isinstance(node.type, ast.Name) - assert node.type.id == "int" + assert node.text + text = node.text.str + assert isinstance(text, str) + assert text.startswith("Module level") + assert text.endswith("by a colon.") + assert node.type + assert isinstance(node.type.expr, ast.Name) + assert node.type.expr.id == "int" def test_move_property_to_attributes(google): cls = google.get("ExampleClass") attr = cls.get("readonly_property") assert isinstance(attr, Attribute) - assert attr.docstring - assert attr.docstring.startswith("Properties should be") - assert ast.unparse(attr.type) == "str" # type: ignore + assert attr.text + assert attr.text.str.startswith("Properties should be") + assert attr.type + assert ast.unparse(attr.type.expr) == "str" attr = cls.get("readwrite_property") assert isinstance(attr, Attribute) - assert attr.docstring - assert attr.docstring.endswith("mentioned here.") - assert ast.unparse(attr.type) == "list[str]" # type: ignore + assert attr.text + assert attr.text.str.endswith("mentioned here.") + assert attr.type + assert ast.unparse(attr.type.expr) == "list[str]" @pytest.fixture() @@ -51,25 +55,31 @@ def module(): def test_merge_module_attrs(module: Module): x = module.get_attribute("module_level_variable1") assert isinstance(x, Attribute) - assert x.docstring - assert x.docstring.startswith("Module level") - assert isinstance(x.type, ast.Name) + assert x.text + assert x.text.str.startswith("Module level") + assert x.type + assert isinstance(x.type.expr, ast.Name) assert isinstance(x.default, ast.Constant) - assert isinstance(module.docstring, Docstring) - assert len(module.docstring.sections) == 5 - assert get_by_name(module.docstring.sections, "Attributes") is None + # TODO: fix + # assert isinstance(module.docstring, Docstring) + # assert len(module.docstring.sections) == 5 + # assert get_by_name(module.docstring.sections, "Attributes") is None def test_merge_function_args(module: Module): f = module.get_function("function_with_types_in_docstring") assert isinstance(f, Function) - assert isinstance(f.docstring, Docstring) - assert len(f.docstring.sections) == 2 + assert f.text + # TODO: fix + # assert isinstance(f.text, Docstring) + # assert len(f.docstring.sections) == 2 p = get_by_name(f.parameters, "param1") assert isinstance(p, Parameter) - assert isinstance(p.type, ast.Name) - assert isinstance(p.docstring, str) - assert p.docstring.startswith("The first") + assert p.type + assert isinstance(p.type.expr, ast.Name) + assert p.text + assert isinstance(p.text.str, str) + assert p.text.str.startswith("The first") def test_merge_function_returns(module: Module): @@ -77,34 +87,42 @@ def test_merge_function_returns(module: Module): assert isinstance(f, Function) r = f.returns assert r.name == "Returns" - assert isinstance(r.type, ast.Name) - assert ast.unparse(r.type) == "bool" - assert isinstance(r.docstring, str) - assert r.docstring.startswith("The return") + assert r.type + assert isinstance(r.type.expr, ast.Name) + assert ast.unparse(r.type.expr) == "bool" + assert r.text + assert isinstance(r.text.str, str) + assert r.text.str.startswith("The return") def test_merge_function_pep484(module: Module): f = module.get_function("function_with_pep484_type_annotations") - x = f.get_parameter("param1") # type: ignore - assert x.docstring.startswith("The first") # type: ignore + assert f + x = f.get_parameter("param1") + assert x + assert x.text + assert x.text.str.startswith("The first") def test_merge_generator(module: Module): g = module.get_function("example_generator") - assert g.returns.name == "Yields" # type: ignore + assert g + assert g.returns.name == "Yields" def test_postprocess_class(module: Module): c = module.get_class("ExampleError") assert isinstance(c, Class) assert len(c.parameters) == 3 # with `self` at this state. - assert len(c.docstring.sections) == 2 # type: ignore + # TODO: fix + # assert len(c.docstring.sections) == 2 # type: ignore assert not c.functions c = module.get_class("ExampleClass") assert isinstance(c, Class) assert len(c.parameters) == 4 # with `self` at this state. - assert len(c.docstring.sections) == 3 # type: ignore - assert ast.unparse(c.parameters[3].type) == "list[str]" # type: ignore + # TODO: fix + # assert len(c.docstring.sections) == 3 # type: ignore + assert ast.unparse(c.parameters[3].type.expr) == "list[str]" # type: ignore assert c.attributes[0].name == "attr1" f = c.get_function("example_method") assert f @@ -115,9 +133,13 @@ def test_postprocess_class_pep526(module: Module): c = module.get_class("ExamplePEP526Class") assert isinstance(c, Class) assert len(c.parameters) == 0 - assert len(c.docstring.sections) == 1 # type: ignore + assert c.text + # TODO: fix + # assert len(c.docstring.sections) == 1 # type: ignore assert not c.functions assert c.attributes assert c.attributes[0].name == "attr1" - assert isinstance(c.attributes[0].type, ast.Name) - assert c.attributes[0].docstring == "Description of `attr1`." + assert c.attributes[0].type + assert isinstance(c.attributes[0].type.expr, ast.Name) + assert c.attributes[0].text + assert c.attributes[0].text.str == "Description of `attr1`." From 112559e1c2d449a1ae398e93e66cd020746d2628 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 12 Jan 2024 09:35:38 +0900 Subject: [PATCH 085/148] get_member --- src/mkapi/objects.py | 88 +++++++++++++++++------------------ tests/objects/test_inherit.py | 9 ++-- tests/objects/test_link.py | 17 ++++--- tests/objects/test_merge.py | 24 +++------- tests/objects/test_object.py | 8 +--- 5 files changed, 67 insertions(+), 79 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 69470ae7..3fabb925 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -5,7 +5,7 @@ import importlib import importlib.util from contextlib import contextmanager -from dataclasses import InitVar, dataclass, field +from dataclasses import InitVar, dataclass, field, fields from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -24,7 +24,7 @@ from inspect import _ParameterKind from typing import Self - from mkapi.docstrings import Docstring, Item, Section + from mkapi.docstrings import Item, Section @dataclass @@ -70,9 +70,15 @@ def get_source(self, maxline: int | None = None) -> str | None: return "\n".join(source.split("\n")[start:stop][:maxline]) return None - def unparse(self) -> str: - """Unparse the AST node and return a string expression.""" - return ast.unparse(self.node) + def iter_types(self) -> Iterator[Type]: + """Yield [Type] instances.""" + if self.type: + yield self.type + + def iter_texts(self) -> Iterator[Text]: + """Yield [Text] instances.""" + if self.text: + yield self.text @dataclass(repr=False) @@ -168,6 +174,25 @@ def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname objects[self.fullname] = self # type:ignore + def iter_objects(self) -> Iterator[Object]: + """Yield [Object] instances.""" + for f in fields(self): + obj = getattr(self, f.name) + if isinstance(obj, Object): + yield obj + + def iter_types(self) -> Iterator[Type]: + """Yield [Type] instances.""" + yield from super().iter_types() + for obj in self.iter_objects(): + yield from obj.iter_types() + + def iter_texts(self) -> Iterator[Text]: + """Yield [Text] instances.""" + yield from super().iter_texts() + for obj in self.iter_objects(): + yield from obj.iter_texts() + @dataclass(repr=False) class Attribute(Member): @@ -175,7 +200,6 @@ class Attribute(Member): node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None default: ast.expr | None - # type_params: list[ast.type_param] | None def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: @@ -185,8 +209,6 @@ def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attrib text, type_ = _attribute_text_type(node.__doc__, type_) default = None if isinstance(node, ast.TypeAlias) else node.value return Attribute(node, name, text, type_, default) - # type_params = node.type_params if isinstance(node, ast.TypeAlias) else None - # return Attribute(node, name, text, type_, default, type_params) def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: @@ -195,9 +217,6 @@ def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: type_ = node.returns text, type_ = _attribute_text_type(doc, type_) return Attribute(node, node.name, text, type_, None) - # default = None - # type_params = node.type_params - # return Attribute(node, name, text, type_, default, type_params) def _attribute_text_type( @@ -222,15 +241,6 @@ class Callable(Member): parameters: list[Parameter] raises: list[Raise] - def __iter__(self) -> Iterator[Parameter | Raise]: - """Yield member instances.""" - yield from self.parameters - yield from self.raises - - def get(self, name: str) -> Parameter | Raise | None: - """Return a member instance by the name.""" - return get_by_name(self, name) - def get_parameter(self, name: str) -> Parameter | None: """Return a [Parameter] instance by the name.""" return get_by_name(self.parameters, name) @@ -258,11 +268,6 @@ class Function(Callable): node: ast.FunctionDef | ast.AsyncFunctionDef returns: Return - def __iter__(self) -> Iterator[Parameter | Raise | Return]: - """Yield member instances.""" - yield from super().__iter__() - yield self.returns - def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: """Return a [Function] instance.""" @@ -280,13 +285,6 @@ class Class(Callable): bases: list[Class] classnames: ClassVar[list[str | None]] = [None] - def __iter__(self) -> Iterator[Parameter | Attribute | Class | Function | Raise]: - """Yield member instances.""" - yield from super().__iter__() - yield from self.attributes - yield from self.classes - yield from self.functions - def add_member(self, member: Attribute | Class | Function | Import) -> None: """Add a member.""" if isinstance(member, Attribute): @@ -378,13 +376,6 @@ def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: self.qualname = self.fullname = self.name modules[self.name] = self - def __iter__(self) -> Iterator[Import | Attribute | Class | Function]: - """Yield member instances.""" - yield from self.imports - yield from self.attributes - yield from self.classes - yield from self.functions - def add_member(self, member: Import | Attribute | Class | Function) -> None: """Add member instance.""" if isinstance(member, Import): @@ -399,10 +390,6 @@ def add_member(self, member: Import | Attribute | Class | Function) -> None: elif isinstance(member, Function): self.functions.append(member) - def get(self, name: str) -> Import | Attribute | Class | Function | None: - """Return a member instance by the name.""" - return get_by_name(self, name) - def get_import(self, name: str) -> Import | None: """Return an [Import] instance by the name.""" return get_by_name(self.imports, name) @@ -419,11 +406,23 @@ def get_function(self, name: str) -> Function | None: """Return an [Function] instance by the name.""" return get_by_name(self.functions, name) + def get_member(self, name: str) -> Import | Attribute | Class | Function | None: + """Return a member instance by the name.""" + if obj := self.get_import(name): + return obj + if obj := self.get_attribute(name): + return obj + if obj := self.get_class(name): + return obj + if obj := self.get_function(name): + return obj + return None + def get_fullname(self, name: str | None = None) -> str | None: """Return the fullname of the module.""" if not name: return self.name - if obj := self.get(name): + if obj := self.get_member(name): return obj.fullname if "." in name: name, attr = name.rsplit(".", maxsplit=1) @@ -559,7 +558,6 @@ def _merge_docstring(obj: Module | Class | Function) -> None: obj.returns.name = section.name else: sections.append(section) - obj.text.str = "xxx" # TODO: sections -> text string def _postprocess(obj: Module | Class) -> None: diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 26d5b397..84687a4a 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -1,3 +1,5 @@ +import ast + from mkapi.objects import Class, get_object, load_module from mkapi.utils import get_by_name @@ -45,11 +47,12 @@ def test_inherit_other_module2(): x = get_by_name(f, "on_pre_template") assert x p = x.parameters[1] - assert p.unparse() == "template: jinja2.Template" + assert p.node + assert ast.unparse(p.node) == "template: jinja2.Template" m = p.module assert m assert m.name == "mkdocs.plugins" - assert m.get("get_plugin_logger") + assert m.get_member("get_plugin_logger") def test_inherit_other_module3(): @@ -62,6 +65,6 @@ def test_inherit_other_module3(): assert m1.get_fullname("jinja2.Template") == a m2 = load_module("jinja2") assert m2 - x = m2.get("Template") + x = m2.get_member("Template") assert x assert x.fullname == a diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 64cb2746..4d96ec90 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,14 +1,17 @@ import ast import mkapi.ast -from mkapi.objects import Module, Object, Parameter, load_module +from mkapi.objects import Module, Object, Parameter, Type, load_module -# def convert(obj:Object): -# mkapi.ast._iter_identifiers +def set_markdown(obj: Type, module: Module) -> None: + def callback(name: str) -> str: + fullname = module.get_fullname(name) + if fullname: + return f"[{name}][__mkapi__.{fullname}]" + return name -# expr:ast.expr|ast.type_param): -# if not module := + obj.markdown = mkapi.ast.unparse(obj.expr, callback) def test_expr_mkapi_objects(): @@ -24,6 +27,6 @@ def callback(x: str) -> str: cls = module.get_class("Class") assert cls for p in cls.parameters: - t = mkapi.ast.unparse(p.type, callback) if p.type else "---" + t = mkapi.ast.unparse(p.type.expr, callback) if p.type else "---" print(p.name, t) - assert 0 + # assert 0 diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py index 982e9302..6e29a65b 100644 --- a/tests/objects/test_merge.py +++ b/tests/objects/test_merge.py @@ -16,7 +16,7 @@ def test_split_attribute_docstring(google): name = "module_level_variable2" - node = google.get(name) + node = google.get_member(name) assert isinstance(node, Attribute) assert node.text text = node.text.str @@ -29,14 +29,14 @@ def test_split_attribute_docstring(google): def test_move_property_to_attributes(google): - cls = google.get("ExampleClass") - attr = cls.get("readonly_property") + cls = google.get_member("ExampleClass") + attr = cls.get_attribute("readonly_property") assert isinstance(attr, Attribute) assert attr.text assert attr.text.str.startswith("Properties should be") assert attr.type assert ast.unparse(attr.type.expr) == "str" - attr = cls.get("readwrite_property") + attr = cls.get_attribute("readwrite_property") assert isinstance(attr, Attribute) assert attr.text assert attr.text.str.endswith("mentioned here.") @@ -60,19 +60,12 @@ def test_merge_module_attrs(module: Module): assert x.type assert isinstance(x.type.expr, ast.Name) assert isinstance(x.default, ast.Constant) - # TODO: fix - # assert isinstance(module.docstring, Docstring) - # assert len(module.docstring.sections) == 5 - # assert get_by_name(module.docstring.sections, "Attributes") is None def test_merge_function_args(module: Module): f = module.get_function("function_with_types_in_docstring") assert isinstance(f, Function) assert f.text - # TODO: fix - # assert isinstance(f.text, Docstring) - # assert len(f.docstring.sections) == 2 p = get_by_name(f.parameters, "param1") assert isinstance(p, Parameter) assert p.type @@ -114,15 +107,12 @@ def test_postprocess_class(module: Module): c = module.get_class("ExampleError") assert isinstance(c, Class) assert len(c.parameters) == 3 # with `self` at this state. - # TODO: fix - # assert len(c.docstring.sections) == 2 # type: ignore assert not c.functions c = module.get_class("ExampleClass") assert isinstance(c, Class) assert len(c.parameters) == 4 # with `self` at this state. - # TODO: fix - # assert len(c.docstring.sections) == 3 # type: ignore - assert ast.unparse(c.parameters[3].type.expr) == "list[str]" # type: ignore + assert c.parameters[3].type + assert ast.unparse(c.parameters[3].type.expr) == "list[str]" assert c.attributes[0].name == "attr1" f = c.get_function("example_method") assert f @@ -134,8 +124,6 @@ def test_postprocess_class_pep526(module: Module): assert isinstance(c, Class) assert len(c.parameters) == 0 assert c.text - # TODO: fix - # assert len(c.docstring.sections) == 1 # type: ignore assert not c.functions assert c.attributes assert c.attributes[0].name == "attr1" diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index dd3f79e7..0ef6a1a5 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -59,7 +59,7 @@ def test_load_module_source(): assert "class File" in module.source module = load_module("mkapi.plugins") assert module - cls = module.get("MkAPIConfig") + cls = module.get_class("MkAPIConfig") assert cls assert cls.module is module src = cls.get_source() @@ -130,13 +130,9 @@ def test_get_fullname_with_attr(): assert not module.get_fullname("config_options.A") -def test_iter(): +def test_iter_bases(): module = load_module("mkapi.objects") assert module - names = [o.name for o in module] - assert "modules" in names - assert "Class" in names - assert "get_object" in names cls = module.get_class("Class") assert cls bases = cls.iter_bases() From ad99846e9c348bda4540052a0d0f874abf2445b6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 12 Jan 2024 10:58:43 +0900 Subject: [PATCH 086/148] Delete contextmanager --- src/mkapi/ast.py | 6 -- src/mkapi/converter.py | 6 ++ src/mkapi/objects.py | 129 +++++++++++++++---------------- src/mkapi/pages.py | 6 -- tests/dataclasses/test_params.py | 26 +++---- tests/objects/test_link.py | 39 ++++------ 6 files changed, 96 insertions(+), 116 deletions(-) diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 0a78d68e..727eeebc 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -128,12 +128,6 @@ def iter_parameters( else: default = next(it) yield arg, kind, default - # if kind is Parameter.VAR_POSITIONAL: - # arg.arg, default = f"*{arg.arg}", None - # elif kind is Parameter.VAR_KEYWORD: - # arg.arg, default = f"**{arg.arg}", None - # else: - # default = next(it) def is_property(decorators: list[ast.expr]) -> bool: diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py index f4edb976..a3b1e249 100644 --- a/src/mkapi/converter.py +++ b/src/mkapi/converter.py @@ -1,8 +1,14 @@ """Converter.""" from __future__ import annotations +from typing import TYPE_CHECKING + +import mkapi.ast from mkapi.objects import load_module +if TYPE_CHECKING: + from mkapi.objects import Module + # from mkapi.renderer import renderer diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 3fabb925..f628d5ab 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -4,8 +4,7 @@ import ast import importlib import importlib.util -from contextlib import contextmanager -from dataclasses import InitVar, dataclass, field, fields +from dataclasses import InitVar, dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -57,8 +56,12 @@ class Object: def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: self.module = Module.current - self.type = Type(_type) if _type else None self.text = Text(_text) if _text else None + self.type = Type(_type) if _type else None + if self.module and self.text: + self.module.add_text(self.text) + if self.module and self.type: + self.module.add_type(self.type) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -168,30 +171,15 @@ class Member(Object): def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: super().__post_init__(_text, _type) - qualname = Class.get_qualclassname() + qualname = Class.classnames[-1] self.qualname = f"{qualname}.{self.name}" if qualname else self.name m_name = self.module.name if self.module else "__mkapi__" self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname objects[self.fullname] = self # type:ignore - def iter_objects(self) -> Iterator[Object]: - """Yield [Object] instances.""" - for f in fields(self): - obj = getattr(self, f.name) - if isinstance(obj, Object): - yield obj - - def iter_types(self) -> Iterator[Type]: - """Yield [Type] instances.""" - yield from super().iter_types() - for obj in self.iter_objects(): - yield from obj.iter_types() - - def iter_texts(self) -> Iterator[Text]: - """Yield [Text] instances.""" - yield from super().iter_texts() - for obj in self.iter_objects(): - yield from obj.iter_texts() + def iter_members(self) -> Iterator[Member]: + """Yield [Member] instances.""" + yield from [] @dataclass(repr=False) @@ -279,10 +267,10 @@ class Class(Callable): """Class class.""" node: ast.ClassDef - attributes: list[Attribute] - classes: list[Class] - functions: list[Function] - bases: list[Class] + attributes: list[Attribute] = field(default_factory=list, init=False) + classes: list[Class] = field(default_factory=list, init=False) + functions: list[Function] = field(default_factory=list, init=False) + bases: list[Class] = field(default_factory=list, init=False) classnames: ClassVar[list[str | None]] = [None] def add_member(self, member: Attribute | Class | Function | Import) -> None: @@ -312,28 +300,16 @@ def iter_bases(self) -> Iterator[Class]: yield from base.iter_bases() yield self - @classmethod - @contextmanager - def create(cls, node: ast.ClassDef) -> Iterator[Self]: - """Create a [Class] instance.""" - name = node.name - klass = cls(node, node.name, *_callable_args(node), [], [], [], []) - qualname = f"{cls.classnames[-1]}.{name}" if cls.classnames[-1] else name - cls.classnames.append(qualname) - yield klass - cls.classnames.pop() - - @classmethod - def get_qualclassname(cls) -> str | None: - """Return current qualified class name.""" - return cls.classnames[-1] - def create_class(node: ast.ClassDef) -> Class: """Return a [Class] instance.""" - with Class.create(node) as cls: - for member in create_members(node): - cls.add_member(member) + name = node.name + cls = Class(node, node.name, *_callable_args(node)) + qualname = f"{Class.classnames[-1]}.{name}" if Class.classnames[-1] else name + Class.classnames.append(qualname) + for member in create_members(node): + cls.add_member(member) + Class.classnames.pop() return cls @@ -363,12 +339,14 @@ class Module(Member): """Module class.""" node: ast.Module - imports: list[Import] - attributes: list[Attribute] - classes: list[Class] - functions: list[Function] - source: str | None - kind: str | None + imports: list[Import] = field(default_factory=list, init=False) + attributes: list[Attribute] = field(default_factory=list, init=False) + classes: list[Class] = field(default_factory=list, init=False) + functions: list[Function] = field(default_factory=list, init=False) + types: list[Type] = field(default_factory=list, init=False) + texts: list[Text] = field(default_factory=list, init=False) + source: str | None = None + kind: str | None = None current: ClassVar[Self | None] = None def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: @@ -376,8 +354,16 @@ def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: self.qualname = self.fullname = self.name modules[self.name] = self + def add_type(self, type_: Type) -> None: + """Add a [Type] instance.""" + self.types.append(type_) + + def add_text(self, text: Text) -> None: + """Add a [Text] instance.""" + self.texts.append(text) + def add_member(self, member: Import | Attribute | Class | Function) -> None: - """Add member instance.""" + """Add a member instance.""" if isinstance(member, Import): if member.level: prefix = ".".join(self.name.split(".")[: member.level]) @@ -437,14 +423,16 @@ def get_source(self, maxline: int | None = None) -> str | None: return None return "\n".join(self.source.split("\n")[:maxline]) - @classmethod - @contextmanager - def create(cls, node: ast.Module, name: str) -> Iterator[Self]: # noqa: D102 - text = ast.get_docstring(node) - module = cls(node, name, text, None, [], [], [], [], None, None) - cls.current = module - yield module - cls.current = None + def _get_link(self, name: str) -> str: + fullname = self.get_fullname(name) + if fullname: + return f"[{name}][__mkapi__.{fullname}]" + return name + + def set_markdown(self) -> None: + """Set markdown with link form.""" + for type_ in self.types: + type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link) def get_module_path(name: str) -> Path | None: @@ -481,18 +469,27 @@ def load_module(name: str) -> Module | None: def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from source string.""" node = ast.parse(source) - module = load_module_from_node(node, name) + module = create_module_from_node(node, name) module.source = source return module -def load_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: +def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from the [ast.Module] node.""" - with Module.create(node, name) as module: - for member in create_members(node): - module.add_member(member) - _postprocess(module) + text = ast.get_docstring(node) + module = Module(node, name, text, None) + if Module.current is not None: + raise NotImplementedError + Module.current = module + for member in create_members(node): + module.add_member(member) + Module.current = None + _postprocess(module) return module + # with Module.create(node, name) as module: + # for member in create_members(node): + # module.add_member(member) + # return module def get_object(fullname: str) -> Module | Class | Function | Attribute | None: diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index d87d0c9e..f0ad23c8 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -9,12 +9,6 @@ from mkapi.link import resolve_link from mkapi.utils import split_filters, update_filters -# from mkapi.core import postprocess -# from mkapi.core.base import Base, Section -# from mkapi.core.code import Code, get_code -# from mkapi.core.inherit import inherit -# from mkapi.core.node import Node, get_node - if TYPE_CHECKING: from collections.abc import Iterator diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py index 3bc9b489..993ec5b5 100644 --- a/tests/dataclasses/test_params.py +++ b/tests/dataclasses/test_params.py @@ -9,7 +9,7 @@ def test_parameters(): assert isinstance(cls, Class) assert is_dataclass(cls) p = cls.parameters - assert len(p) == 10 + assert len(p) == 6 assert p[0].name == "node" assert p[0].type assert ast.unparse(p[0].type.expr) == "ast.ClassDef" @@ -32,15 +32,15 @@ def test_parameters(): assert p[5].name == "raises" assert p[5].type assert ast.unparse(p[5].type.expr) == "list[Raise]" - assert p[6].name == "attributes" - assert p[6].type - assert ast.unparse(p[6].type.expr) == "list[Attribute]" - assert p[7].name == "classes" - assert p[7].type - assert ast.unparse(p[7].type.expr) == "list[Class]" - assert p[8].name == "functions" - assert p[8].type - assert ast.unparse(p[8].type.expr) == "list[Function]" - assert p[9].name == "bases" - assert p[9].type - assert ast.unparse(p[9].type.expr) == "list[Class]" + # assert p[6].name == "attributes" + # assert p[6].type + # assert ast.unparse(p[6].type.expr) == "list[Attribute]" + # assert p[7].name == "classes" + # assert p[7].type + # assert ast.unparse(p[7].type.expr) == "list[Class]" + # assert p[8].name == "functions" + # assert p[8].type + # assert ast.unparse(p[8].type.expr) == "list[Function]" + # assert p[9].name == "bases" + # assert p[9].type + # assert ast.unparse(p[9].type.expr) == "list[Class]" diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 4d96ec90..04c24d7a 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,32 +1,21 @@ import ast import mkapi.ast -from mkapi.objects import Module, Object, Parameter, Type, load_module +from mkapi.objects import load_module -def set_markdown(obj: Type, module: Module) -> None: - def callback(name: str) -> str: - fullname = module.get_fullname(name) - if fullname: - return f"[{name}][__mkapi__.{fullname}]" - return name - - obj.markdown = mkapi.ast.unparse(obj.expr, callback) - - -def test_expr_mkapi_objects(): +def test_set_markdown(): module = load_module("mkapi.objects") assert module - - def callback(x: str) -> str: - fullname = module.get_fullname(x) - if fullname: - return f"[{x}][__mkapi__.{fullname}]" - return x - - cls = module.get_class("Class") - assert cls - for p in cls.parameters: - t = mkapi.ast.unparse(p.type.expr, callback) if p.type else "---" - print(p.name, t) - # assert 0 + module.set_markdown() + x = [t.markdown for t in module.types] + assert "list[[Item][__mkapi__.mkapi.docstrings.Item]]" in x + assert "[Path][__mkapi__.pathlib.Path] | None" in x + assert "NotImplementedError" in x + assert "[InitVar][__mkapi__.dataclasses.InitVar][str | None]" in x + + for type in module.types: + assert isinstance(type.markdown, str) + print(type.markdown) + + assert 0 From f3462440f13af45f0b5c15e8fd69c4524bbceb6c Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 12 Jan 2024 11:26:49 +0900 Subject: [PATCH 087/148] link --- src/mkapi/objects.py | 28 +++++++++++++-------------- tests/objects/test_link.py | 39 ++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index f628d5ab..761eddb1 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -115,8 +115,10 @@ def __repr__(self) -> str: def create_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" for child in ast.walk(node): - if isinstance(child, ast.Raise) and child.exc: - yield Raise(child, "", None, child.exc) + if isinstance(child, ast.Raise) and (name := child.exc): + if isinstance(name, ast.Call): + name = name.func + yield Raise(child, "", None, name) @dataclass(repr=False) @@ -486,10 +488,6 @@ def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module Module.current = None _postprocess(module) return module - # with Module.create(node, name) as module: - # for member in create_members(node): - # module.add_member(member) - # return module def get_object(fullname: str) -> Module | Class | Function | Attribute | None: @@ -503,6 +501,15 @@ def get_object(fullname: str) -> Module | Class | Function | Attribute | None: return None +def _postprocess(obj: Module | Class) -> None: + _merge_docstring(obj) + for func in obj.functions: + _merge_docstring(func) + for cls in obj.classes: + _postprocess(cls) + _postprocess_class(cls) + + def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: if not obj.type and item.type: # ex. list(str) -> list[str] @@ -557,15 +564,6 @@ def _merge_docstring(obj: Module | Class | Function) -> None: sections.append(section) -def _postprocess(obj: Module | Class) -> None: - _merge_docstring(obj) - for func in obj.functions: - _merge_docstring(func) - for cls in obj.classes: - _postprocess(cls) - _postprocess_class(cls) - - ATTRIBUTE_ORDER_DICT = { ast.AnnAssign: 1, ast.Assign: 2, diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 04c24d7a..7526ce6d 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,10 +1,7 @@ -import ast - -import mkapi.ast from mkapi.objects import load_module -def test_set_markdown(): +def test_set_markdown_objects(): module = load_module("mkapi.objects") assert module module.set_markdown() @@ -14,8 +11,34 @@ def test_set_markdown(): assert "NotImplementedError" in x assert "[InitVar][__mkapi__.dataclasses.InitVar][str | None]" in x - for type in module.types: - assert isinstance(type.markdown, str) - print(type.markdown) - assert 0 +def test_set_markdown_plugins(): + module = load_module("mkapi.plugins") + assert module + module.set_markdown() + x = [t.markdown for t in module.types] + assert "[MkDocsConfig][__mkapi__.mkdocs.config.defaults.MkDocsConfig]" in x + assert "[MkDocsPage][__mkapi__.mkdocs.structure.pages.Page]" in x + assert "[MkAPIConfig][__mkapi__.mkapi.plugins.MkAPIConfig]" in x + assert "[TypeGuard][__mkapi__.typing.TypeGuard][str]" in x + assert "[Callable][__mkapi__.collections.abc.Callable] | None" in x + + +def test_set_markdown_bases(): + module = load_module("mkapi.plugins") + assert module + cls = module.get_class("MkAPIConfig") + assert cls + assert cls.bases + cls = cls.bases[0] + module = cls.module + assert module + module.set_markdown() + x = [t.markdown for t in module.types] + assert "[Config][__mkapi__.mkdocs.config.base.Config]" in x + assert "[T][__mkapi__.mkdocs.config.base.T]" in x + assert "[ValidationError][__mkapi__.mkdocs.config.base.ValidationError]" in x + assert "[IO][__mkapi__.typing.IO]" in x + assert "[PlainConfigSchema][__mkapi__.mkdocs.config.base.PlainConfigSchema]" in x + assert "str | [IO][__mkapi__.typing.IO] | None" in x + assert "[Iterator][__mkapi__.typing.Iterator][[IO][__mkapi__.typing.IO]]" in x From 48975c8502030a6b7a780aad88520fa9685b2beb Mon Sep 17 00:00:00 2001 From: daizutabi Date: Fri, 12 Jan 2024 20:48:26 +0900 Subject: [PATCH 088/148] delete old --- examples/docs/index.md | 12 +- examples_old/__init__.py | 1 - examples_old/cls/abc.py | 48 --- examples_old/cls/decorated.py | 33 -- examples_old/cls/decorator.py | 16 - examples_old/cls/inherit.py | 27 -- examples_old/cls/member_order_base.py | 14 - examples_old/cls/member_order_sub.py | 11 - examples_old/cls/method.py | 34 -- examples_old/custom.py | 11 - examples_old/docs/1.md | 7 - examples_old/docs/2.md | 7 - examples_old/docs/api/mkapi.objects.md | 1 - .../docs/api/mkdocs.commands.build.md | 1 - .../docs/api/mkdocs.commands.get_deps.md | 1 - .../docs/api/mkdocs.commands.gh_deploy.md | 1 - examples_old/docs/api/mkdocs.commands.md | 1 - examples_old/docs/api/mkdocs.commands.new.md | 1 - .../docs/api/mkdocs.commands.serve.md | 1 - examples_old/docs/api/mkdocs.config.base.md | 1 - .../docs/api/mkdocs.config.config_options.md | 1 - .../docs/api/mkdocs.config.defaults.md | 1 - examples_old/docs/api/mkdocs.config.md | 1 - examples_old/docs/api/mkdocs.contrib.md | 1 - .../docs/api/mkdocs.contrib.search.md | 1 - .../api/mkdocs.contrib.search.search_index.md | 1 - examples_old/docs/api/mkdocs.exceptions.md | 1 - examples_old/docs/api/mkdocs.livereload.md | 1 - examples_old/docs/api/mkdocs.localization.md | 1 - examples_old/docs/api/mkdocs.md | 1 - examples_old/docs/api/mkdocs.plugins.md | 1 - .../docs/api/mkdocs.structure.files.md | 1 - examples_old/docs/api/mkdocs.structure.md | 1 - examples_old/docs/api/mkdocs.structure.nav.md | 1 - .../docs/api/mkdocs.structure.pages.md | 1 - examples_old/docs/api/mkdocs.structure.toc.md | 1 - examples_old/docs/api/mkdocs.theme.md | 1 - examples_old/docs/api/mkdocs.themes.md | 1 - examples_old/docs/api/mkdocs.themes.mkdocs.md | 1 - .../docs/api/mkdocs.themes.readthedocs.md | 1 - .../docs/api/mkdocs.utils.babel_stub.md | 1 - examples_old/docs/api/mkdocs.utils.cache.md | 1 - examples_old/docs/api/mkdocs.utils.filters.md | 1 - examples_old/docs/api/mkdocs.utils.md | 1 - examples_old/docs/api/mkdocs.utils.meta.md | 1 - .../docs/api/mkdocs.utils.templates.md | 1 - examples_old/docs/api/mkdocs.utils.yaml.md | 1 - examples_old/docs/api2/mkapi.config.md | 1 - examples_old/docs/api2/mkapi.converter.md | 1 - examples_old/docs/api2/mkapi.docstrings.md | 1 - examples_old/docs/api2/mkapi.filter.md | 1 - examples_old/docs/api2/mkapi.inspect.md | 1 - examples_old/docs/api2/mkapi.link.md | 1 - examples_old/docs/api2/mkapi.md | 1 - examples_old/docs/api2/mkapi.nodes.md | 1 - examples_old/docs/api2/mkapi.objects.md | 1 - examples_old/docs/api2/mkapi.pages.md | 1 - examples_old/docs/api2/mkapi.plugins.md | 1 - examples_old/docs/api2/mkapi.renderer.md | 1 - examples_old/docs/api2/mkapi.utils.md | 1 - examples_old/docs/custom.css | 3 - examples_old/docs/index.md | 13 - examples_old/inherit.py | 48 --- examples_old/inherit_comment.py | 30 -- examples_old/inspect.py | 12 - examples_old/link/__init__.py | 0 examples_old/link/fullname.py | 6 - examples_old/link/qualname.py | 14 - examples_old/meta.py | 35 -- examples_old/mkdocs.yml | 36 -- examples_old/styles/__init__.py | 4 - examples_old/styles/example_google.py | 314 ---------------- examples_old/styles/example_numpy.py | 355 ------------------ src/mkapi/converter.py | 28 -- src/mkapi/converters.py | 83 ++++ src/mkapi/link.py | 10 +- src/mkapi/objects.py | 45 ++- src/mkapi/pages.py | 32 +- src/mkapi/plugins.py | 74 ++-- src/mkapi/{renderer.py => renderers.py} | 41 +- src/mkapi/templates/module.jinja2 | 38 +- src/mkapi/templates/module_old.jinja2 | 17 + tests/objects/test_link.py | 36 +- tests/pages/__init__.py | 0 .../cls => tests/renderers}/__init__.py | 0 .../test_module.py} | 18 +- tests/renderers/test_templates.py | 39 ++ .../test_page.py => test_converters.py} | 53 ++- 88 files changed, 379 insertions(+), 1274 deletions(-) delete mode 100644 examples_old/__init__.py delete mode 100644 examples_old/cls/abc.py delete mode 100644 examples_old/cls/decorated.py delete mode 100644 examples_old/cls/decorator.py delete mode 100644 examples_old/cls/inherit.py delete mode 100644 examples_old/cls/member_order_base.py delete mode 100644 examples_old/cls/member_order_sub.py delete mode 100644 examples_old/cls/method.py delete mode 100644 examples_old/custom.py delete mode 100644 examples_old/docs/1.md delete mode 100644 examples_old/docs/2.md delete mode 100644 examples_old/docs/api/mkapi.objects.md delete mode 100644 examples_old/docs/api/mkdocs.commands.build.md delete mode 100644 examples_old/docs/api/mkdocs.commands.get_deps.md delete mode 100644 examples_old/docs/api/mkdocs.commands.gh_deploy.md delete mode 100644 examples_old/docs/api/mkdocs.commands.md delete mode 100644 examples_old/docs/api/mkdocs.commands.new.md delete mode 100644 examples_old/docs/api/mkdocs.commands.serve.md delete mode 100644 examples_old/docs/api/mkdocs.config.base.md delete mode 100644 examples_old/docs/api/mkdocs.config.config_options.md delete mode 100644 examples_old/docs/api/mkdocs.config.defaults.md delete mode 100644 examples_old/docs/api/mkdocs.config.md delete mode 100644 examples_old/docs/api/mkdocs.contrib.md delete mode 100644 examples_old/docs/api/mkdocs.contrib.search.md delete mode 100644 examples_old/docs/api/mkdocs.contrib.search.search_index.md delete mode 100644 examples_old/docs/api/mkdocs.exceptions.md delete mode 100644 examples_old/docs/api/mkdocs.livereload.md delete mode 100644 examples_old/docs/api/mkdocs.localization.md delete mode 100644 examples_old/docs/api/mkdocs.md delete mode 100644 examples_old/docs/api/mkdocs.plugins.md delete mode 100644 examples_old/docs/api/mkdocs.structure.files.md delete mode 100644 examples_old/docs/api/mkdocs.structure.md delete mode 100644 examples_old/docs/api/mkdocs.structure.nav.md delete mode 100644 examples_old/docs/api/mkdocs.structure.pages.md delete mode 100644 examples_old/docs/api/mkdocs.structure.toc.md delete mode 100644 examples_old/docs/api/mkdocs.theme.md delete mode 100644 examples_old/docs/api/mkdocs.themes.md delete mode 100644 examples_old/docs/api/mkdocs.themes.mkdocs.md delete mode 100644 examples_old/docs/api/mkdocs.themes.readthedocs.md delete mode 100644 examples_old/docs/api/mkdocs.utils.babel_stub.md delete mode 100644 examples_old/docs/api/mkdocs.utils.cache.md delete mode 100644 examples_old/docs/api/mkdocs.utils.filters.md delete mode 100644 examples_old/docs/api/mkdocs.utils.md delete mode 100644 examples_old/docs/api/mkdocs.utils.meta.md delete mode 100644 examples_old/docs/api/mkdocs.utils.templates.md delete mode 100644 examples_old/docs/api/mkdocs.utils.yaml.md delete mode 100644 examples_old/docs/api2/mkapi.config.md delete mode 100644 examples_old/docs/api2/mkapi.converter.md delete mode 100644 examples_old/docs/api2/mkapi.docstrings.md delete mode 100644 examples_old/docs/api2/mkapi.filter.md delete mode 100644 examples_old/docs/api2/mkapi.inspect.md delete mode 100644 examples_old/docs/api2/mkapi.link.md delete mode 100644 examples_old/docs/api2/mkapi.md delete mode 100644 examples_old/docs/api2/mkapi.nodes.md delete mode 100644 examples_old/docs/api2/mkapi.objects.md delete mode 100644 examples_old/docs/api2/mkapi.pages.md delete mode 100644 examples_old/docs/api2/mkapi.plugins.md delete mode 100644 examples_old/docs/api2/mkapi.renderer.md delete mode 100644 examples_old/docs/api2/mkapi.utils.md delete mode 100644 examples_old/docs/custom.css delete mode 100644 examples_old/docs/index.md delete mode 100644 examples_old/inherit.py delete mode 100644 examples_old/inherit_comment.py delete mode 100644 examples_old/inspect.py delete mode 100644 examples_old/link/__init__.py delete mode 100644 examples_old/link/fullname.py delete mode 100644 examples_old/link/qualname.py delete mode 100644 examples_old/meta.py delete mode 100644 examples_old/mkdocs.yml delete mode 100644 examples_old/styles/__init__.py delete mode 100644 examples_old/styles/example_google.py delete mode 100644 examples_old/styles/example_numpy.py delete mode 100644 src/mkapi/converter.py create mode 100644 src/mkapi/converters.py rename src/mkapi/{renderer.py => renderers.py} (81%) create mode 100644 src/mkapi/templates/module_old.jinja2 delete mode 100644 tests/pages/__init__.py rename {examples_old/cls => tests/renderers}/__init__.py (100%) rename tests/{test_renderer.py => renderers/test_module.py} (78%) create mode 100644 tests/renderers/test_templates.py rename tests/{pages/test_page.py => test_converters.py} (52%) diff --git a/examples/docs/index.md b/examples/docs/index.md index cefbeac2..4bd5efd7 100644 --- a/examples/docs/index.md +++ b/examples/docs/index.md @@ -1,13 +1,3 @@ # Home -[Object][mkapi.objects] - -[ABC][bdc] - -## ![mkapi](mkapi.objects) - -## ![mkapi](mkapi.objects.Object) - -## ![mkapi](mkapi.objects.Module) - -### [Object](mkapi.objects.Object) +::: mkapi.objects.Module diff --git a/examples_old/__init__.py b/examples_old/__init__.py deleted file mode 100644 index 2926481d..00000000 --- a/examples_old/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Example package for MkAPI test.""" diff --git a/examples_old/cls/abc.py b/examples_old/cls/abc.py deleted file mode 100644 index 11dc8763..00000000 --- a/examples_old/cls/abc.py +++ /dev/null @@ -1,48 +0,0 @@ -from abc import ABC, abstractmethod - - -class AbstractMethodTypeExample(ABC): - """Abstract class.""" - - def method(self): - """Method.""" - return self - - @classmethod - def class_method(cls): - """Class method.""" - return cls - - @staticmethod - def static_method(): - """Static method.""" - return True - - @abstractmethod - def abstract_method(self): - """Abstract method.""" - - @classmethod - @abstractmethod - def abstract_class_method(cls): - """Abstract class method.""" - - @staticmethod - @abstractmethod - def abstract_static_method(): - """Abstract static method.""" - - @property - @abstractmethod - def abstract_read_only_property(self): - """Abstract read only property.""" - - @property - @abstractmethod - def abstract_read_write_property(self): - """Abstract read write property.""" - - @abstract_read_write_property.setter - @abstractmethod - def abstract_read_write_property(self, val): - pass diff --git a/examples_old/cls/decorated.py b/examples_old/cls/decorated.py deleted file mode 100644 index 1297269b..00000000 --- a/examples_old/cls/decorated.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Decorator examples.""" -import pytest - -from examples.cls.decorator import deco_with_wraps, deco_without_wraps - - -@deco_without_wraps -def func_without_wraps(): - """Decorated function without `wraps`.""" - - -@deco_with_wraps -def func_with_wraps(): - """Decorated function with `wraps`.""" - - -@deco_with_wraps -@deco_with_wraps -def func_with_wraps_double(): - """Doubly decorated function with `wraps`.""" - - -@pytest.fixture() -def fixture(): - """Fixture.""" - return 1 - - -@pytest.fixture() -@deco_with_wraps -def fixture_with_wraps(): - """Fixture.""" - return 1 diff --git a/examples_old/cls/decorator.py b/examples_old/cls/decorator.py deleted file mode 100644 index b321f786..00000000 --- a/examples_old/cls/decorator.py +++ /dev/null @@ -1,16 +0,0 @@ -from functools import wraps - - -def deco_without_wraps(func): - def _func(*args, **kwargs): - return func(*args, **kwargs) - - return _func - - -def deco_with_wraps(func): - @wraps(func) - def _func(*args, **kwargs): - return func(*args, **kwargs) - - return _func diff --git a/examples_old/cls/inherit.py b/examples_old/cls/inherit.py deleted file mode 100644 index 4847583c..00000000 --- a/examples_old/cls/inherit.py +++ /dev/null @@ -1,27 +0,0 @@ -class Base: - """Base class.""" - - def func(self): - """Function.""" - - -class Sub(Base): - """Subclass.""" - - # Should be added. - def func(self): - pass - - # Should not be added. - def __call__(self): - pass - - # Should not be added. - def __repr__(self): - pass - - # Should not be added. - def __str__(self): - pass - - # and so on. diff --git a/examples_old/cls/member_order_base.py b/examples_old/cls/member_order_base.py deleted file mode 100644 index f05959b9..00000000 --- a/examples_old/cls/member_order_base.py +++ /dev/null @@ -1,14 +0,0 @@ -# other code - -# other code - -# other code - -# other code - -# other code - - -class A: - def a(self): - """mro index: 1, sourcefile index: 1, line number: 13.""" diff --git a/examples_old/cls/member_order_sub.py b/examples_old/cls/member_order_sub.py deleted file mode 100644 index 15c94a8b..00000000 --- a/examples_old/cls/member_order_sub.py +++ /dev/null @@ -1,11 +0,0 @@ -from examples.cls.member_order_base import A - - -class B: - def b(self): - """Mro index: 2, sourcefile index: 0, line number: 5.""" - - -class C(A, B): - def c(self): - """Mro index: 0, sourcefile index: 0, line number: 10.""" diff --git a/examples_old/cls/method.py b/examples_old/cls/method.py deleted file mode 100644 index e19fcd49..00000000 --- a/examples_old/cls/method.py +++ /dev/null @@ -1,34 +0,0 @@ -class MethodTypeExample: - """Example class.""" - - def __init__(self, x: int): - self._x = x - - def method(self, x): - """Method.""" - - def generator(self, x): - """Generator.""" - yield x + 1 - - @classmethod - def class_method(cls, x): - """Class method.""" - - @staticmethod - def static_method(x): - """Static method.""" - - @property - def read_only_property(self): - """Read only property.""" - return self._x - - @property - def read_write_property(self): - """Read write property.""" - return self._x - - @read_write_property.setter - def read_write_property(self, x): - self._x = x diff --git a/examples_old/custom.py b/examples_old/custom.py deleted file mode 100644 index ec3f69ca..00000000 --- a/examples_old/custom.py +++ /dev/null @@ -1,11 +0,0 @@ -def on_config(config, mkapi): - print("Called with config and mkapi.") - return config - - -def page_title(modulename: str, depth: int, ispackage: bool) -> str: - return ".".join(modulename.split(".")[depth:]) - - -def section_title(package_name: str, depth: int) -> str: - return ".".join(package_name.split(".")[depth:]) diff --git a/examples_old/docs/1.md b/examples_old/docs/1.md deleted file mode 100644 index 3db15f64..00000000 --- a/examples_old/docs/1.md +++ /dev/null @@ -1,7 +0,0 @@ -# 3 - -## 1-1 - -## 1-2 - -## 1-3 {#123} diff --git a/examples_old/docs/2.md b/examples_old/docs/2.md deleted file mode 100644 index 839c1e48..00000000 --- a/examples_old/docs/2.md +++ /dev/null @@ -1,7 +0,0 @@ -# 2 - -## 2-1 - -## 2-2 - -## 2-3 diff --git a/examples_old/docs/api/mkapi.objects.md b/examples_old/docs/api/mkapi.objects.md deleted file mode 100644 index c2704ca4..00000000 --- a/examples_old/docs/api/mkapi.objects.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.objects): 2664400669872 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.build.md b/examples_old/docs/api/mkdocs.commands.build.md deleted file mode 100644 index 2ac0698b..00000000 --- a/examples_old/docs/api/mkdocs.commands.build.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands.build): 2664410679584 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.get_deps.md b/examples_old/docs/api/mkdocs.commands.get_deps.md deleted file mode 100644 index 04bc48de..00000000 --- a/examples_old/docs/api/mkdocs.commands.get_deps.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands.get_deps): 2664410685728 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.gh_deploy.md b/examples_old/docs/api/mkdocs.commands.gh_deploy.md deleted file mode 100644 index a1355084..00000000 --- a/examples_old/docs/api/mkdocs.commands.gh_deploy.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands.gh_deploy): 2664410688224 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.md b/examples_old/docs/api/mkdocs.commands.md deleted file mode 100644 index 077aef8f..00000000 --- a/examples_old/docs/api/mkdocs.commands.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands): 2664410679632 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.new.md b/examples_old/docs/api/mkdocs.commands.new.md deleted file mode 100644 index 90e3c615..00000000 --- a/examples_old/docs/api/mkdocs.commands.new.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands.new): 2664410690528 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.commands.serve.md b/examples_old/docs/api/mkdocs.commands.serve.md deleted file mode 100644 index 80e13e8b..00000000 --- a/examples_old/docs/api/mkdocs.commands.serve.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.commands.serve): 2664410690768 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.base.md b/examples_old/docs/api/mkdocs.config.base.md deleted file mode 100644 index 0341eb74..00000000 --- a/examples_old/docs/api/mkdocs.config.base.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.config.base): 2664410693264 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.config_options.md b/examples_old/docs/api/mkdocs.config.config_options.md deleted file mode 100644 index 8480637a..00000000 --- a/examples_old/docs/api/mkdocs.config.config_options.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.config.config_options): 2664410692592 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.defaults.md b/examples_old/docs/api/mkdocs.config.defaults.md deleted file mode 100644 index 3fde904a..00000000 --- a/examples_old/docs/api/mkdocs.config.defaults.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.config.defaults): 2664410705472 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.config.md b/examples_old/docs/api/mkdocs.config.md deleted file mode 100644 index d32f5bc4..00000000 --- a/examples_old/docs/api/mkdocs.config.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.config): 2664410693120 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.md b/examples_old/docs/api/mkdocs.contrib.md deleted file mode 100644 index d72727a5..00000000 --- a/examples_old/docs/api/mkdocs.contrib.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.contrib): 2664410972480 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.search.md b/examples_old/docs/api/mkdocs.contrib.search.md deleted file mode 100644 index e5d034ab..00000000 --- a/examples_old/docs/api/mkdocs.contrib.search.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.contrib.search): 2664415527760 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.contrib.search.search_index.md b/examples_old/docs/api/mkdocs.contrib.search.search_index.md deleted file mode 100644 index dbed6a87..00000000 --- a/examples_old/docs/api/mkdocs.contrib.search.search_index.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.contrib.search.search_index): 2664415680112 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.exceptions.md b/examples_old/docs/api/mkdocs.exceptions.md deleted file mode 100644 index 634a3e92..00000000 --- a/examples_old/docs/api/mkdocs.exceptions.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.exceptions): 2664415527664 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.livereload.md b/examples_old/docs/api/mkdocs.livereload.md deleted file mode 100644 index daa7d082..00000000 --- a/examples_old/docs/api/mkdocs.livereload.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.livereload): 2664410692928 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.localization.md b/examples_old/docs/api/mkdocs.localization.md deleted file mode 100644 index 6bcda943..00000000 --- a/examples_old/docs/api/mkdocs.localization.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.localization): 2664416059824 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.md b/examples_old/docs/api/mkdocs.md deleted file mode 100644 index 2d3a96ce..00000000 --- a/examples_old/docs/api/mkdocs.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs): 2664410679200 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.plugins.md b/examples_old/docs/api/mkdocs.plugins.md deleted file mode 100644 index fc858ca5..00000000 --- a/examples_old/docs/api/mkdocs.plugins.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.plugins): 2664416061216 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.files.md b/examples_old/docs/api/mkdocs.structure.files.md deleted file mode 100644 index 1070d6c3..00000000 --- a/examples_old/docs/api/mkdocs.structure.files.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.structure.files): 2664415686352 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.md b/examples_old/docs/api/mkdocs.structure.md deleted file mode 100644 index 1296d855..00000000 --- a/examples_old/docs/api/mkdocs.structure.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.structure): 2664415688656 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.nav.md b/examples_old/docs/api/mkdocs.structure.nav.md deleted file mode 100644 index de9cfb77..00000000 --- a/examples_old/docs/api/mkdocs.structure.nav.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.structure.nav): 2664415685824 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.pages.md b/examples_old/docs/api/mkdocs.structure.pages.md deleted file mode 100644 index d3b4dae0..00000000 --- a/examples_old/docs/api/mkdocs.structure.pages.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.structure.pages): 2664415721040 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.structure.toc.md b/examples_old/docs/api/mkdocs.structure.toc.md deleted file mode 100644 index d5d62942..00000000 --- a/examples_old/docs/api/mkdocs.structure.toc.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.structure.toc): 2664415721568 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.theme.md b/examples_old/docs/api/mkdocs.theme.md deleted file mode 100644 index 7e6e7a1a..00000000 --- a/examples_old/docs/api/mkdocs.theme.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.theme): 2664416066112 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.md b/examples_old/docs/api/mkdocs.themes.md deleted file mode 100644 index 12cfddf6..00000000 --- a/examples_old/docs/api/mkdocs.themes.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.themes): 2664415718400 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.mkdocs.md b/examples_old/docs/api/mkdocs.themes.mkdocs.md deleted file mode 100644 index 4d11358f..00000000 --- a/examples_old/docs/api/mkdocs.themes.mkdocs.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.themes.mkdocs): 2664392576896 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.themes.readthedocs.md b/examples_old/docs/api/mkdocs.themes.readthedocs.md deleted file mode 100644 index 7f78590a..00000000 --- a/examples_old/docs/api/mkdocs.themes.readthedocs.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.themes.readthedocs): 2664410443664 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.babel_stub.md b/examples_old/docs/api/mkdocs.utils.babel_stub.md deleted file mode 100644 index aae7e057..00000000 --- a/examples_old/docs/api/mkdocs.utils.babel_stub.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.babel_stub): 2664416045360 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.cache.md b/examples_old/docs/api/mkdocs.utils.cache.md deleted file mode 100644 index f76cb540..00000000 --- a/examples_old/docs/api/mkdocs.utils.cache.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.cache): 2664416045600 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.filters.md b/examples_old/docs/api/mkdocs.utils.filters.md deleted file mode 100644 index c943df41..00000000 --- a/examples_old/docs/api/mkdocs.utils.filters.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.filters): 2664386684752 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.md b/examples_old/docs/api/mkdocs.utils.md deleted file mode 100644 index 8a45c3cd..00000000 --- a/examples_old/docs/api/mkdocs.utils.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils): 2664415686064 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.meta.md b/examples_old/docs/api/mkdocs.utils.meta.md deleted file mode 100644 index 71cb06f8..00000000 --- a/examples_old/docs/api/mkdocs.utils.meta.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.meta): 2664416047856 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.templates.md b/examples_old/docs/api/mkdocs.utils.templates.md deleted file mode 100644 index e4fd6034..00000000 --- a/examples_old/docs/api/mkdocs.utils.templates.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.templates): 2664416049008 \ No newline at end of file diff --git a/examples_old/docs/api/mkdocs.utils.yaml.md b/examples_old/docs/api/mkdocs.utils.yaml.md deleted file mode 100644 index 41e239d2..00000000 --- a/examples_old/docs/api/mkdocs.utils.yaml.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkdocs.utils.yaml): 2664416048960 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.config.md b/examples_old/docs/api2/mkapi.config.md deleted file mode 100644 index 2cc69daa..00000000 --- a/examples_old/docs/api2/mkapi.config.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.config): 2664410437280 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.converter.md b/examples_old/docs/api2/mkapi.converter.md deleted file mode 100644 index 47eebd9b..00000000 --- a/examples_old/docs/api2/mkapi.converter.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.converter): 2664356502016 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.docstrings.md b/examples_old/docs/api2/mkapi.docstrings.md deleted file mode 100644 index c9524d04..00000000 --- a/examples_old/docs/api2/mkapi.docstrings.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.docstrings): 2664410445056 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.filter.md b/examples_old/docs/api2/mkapi.filter.md deleted file mode 100644 index f92c8132..00000000 --- a/examples_old/docs/api2/mkapi.filter.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.filter): 2664410518720 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.inspect.md b/examples_old/docs/api2/mkapi.inspect.md deleted file mode 100644 index b511baab..00000000 --- a/examples_old/docs/api2/mkapi.inspect.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.inspect): 2664391966688 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.link.md b/examples_old/docs/api2/mkapi.link.md deleted file mode 100644 index 97540cd1..00000000 --- a/examples_old/docs/api2/mkapi.link.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.link): 2664400667520 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.md b/examples_old/docs/api2/mkapi.md deleted file mode 100644 index db3c266c..00000000 --- a/examples_old/docs/api2/mkapi.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi): 2664410434352 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.nodes.md b/examples_old/docs/api2/mkapi.nodes.md deleted file mode 100644 index f28c22f6..00000000 --- a/examples_old/docs/api2/mkapi.nodes.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.nodes): 2664410520640 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.objects.md b/examples_old/docs/api2/mkapi.objects.md deleted file mode 100644 index c2704ca4..00000000 --- a/examples_old/docs/api2/mkapi.objects.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.objects): 2664400669872 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.pages.md b/examples_old/docs/api2/mkapi.pages.md deleted file mode 100644 index 01335a5e..00000000 --- a/examples_old/docs/api2/mkapi.pages.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.pages): 2664410526928 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.plugins.md b/examples_old/docs/api2/mkapi.plugins.md deleted file mode 100644 index 82c47819..00000000 --- a/examples_old/docs/api2/mkapi.plugins.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.plugins): 2664410661760 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.renderer.md b/examples_old/docs/api2/mkapi.renderer.md deleted file mode 100644 index 1210e87b..00000000 --- a/examples_old/docs/api2/mkapi.renderer.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.renderer): 2664410670304 \ No newline at end of file diff --git a/examples_old/docs/api2/mkapi.utils.md b/examples_old/docs/api2/mkapi.utils.md deleted file mode 100644 index 0028e533..00000000 --- a/examples_old/docs/api2/mkapi.utils.md +++ /dev/null @@ -1 +0,0 @@ -Module(mkapi.utils): 2664410672464 \ No newline at end of file diff --git a/examples_old/docs/custom.css b/examples_old/docs/custom.css deleted file mode 100644 index 543a6c4a..00000000 --- a/examples_old/docs/custom.css +++ /dev/null @@ -1,3 +0,0 @@ -h1, h2, h3 { - margin-bottom: 10px; -} diff --git a/examples_old/docs/index.md b/examples_old/docs/index.md deleted file mode 100644 index cefbeac2..00000000 --- a/examples_old/docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# Home - -[Object][mkapi.objects] - -[ABC][bdc] - -## ![mkapi](mkapi.objects) - -## ![mkapi](mkapi.objects.Object) - -## ![mkapi](mkapi.objects.Module) - -### [Object](mkapi.objects.Object) diff --git a/examples_old/inherit.py b/examples_old/inherit.py deleted file mode 100644 index fe49daf3..00000000 --- a/examples_old/inherit.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass - -from mkapi.core.base import Type - - -@dataclass -class Base: - """Base class. - - Parameters: - name: Object name. - - Attributes: - name: Object name. - """ - - name: str - type: Type # noqa: A003 - - def set_name(self, name: str): - """Set name. - - Args: - name: A New name. - """ - self.name = name - - def get(self): - """Return {class} instace.""" - return self - - -@dataclass -class Item(Base): - """Item class. - - Parameters: - markdown: Object markdown. - - Attributes: - markdown: Object markdown. - """ - - markdown: str - - def set_name(self, name: str): - """Set name in upper case.""" - self.name = name.upper() diff --git a/examples_old/inherit_comment.py b/examples_old/inherit_comment.py deleted file mode 100644 index 4131ce05..00000000 --- a/examples_old/inherit_comment.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass - -from mkapi.core.base import Type - - -@dataclass -class Base: - """Base class.""" - - name: str #: Object name. - type: Type #: Object type. # noqa: A003 - - def set_name(self, name: str): - """Set name. - - Args: - name: A New name. - """ - self.name = name - - -@dataclass -class Item(Base): - """Item class.""" - - markdown: str #: Object Markdown. - - def set_name(self, name: str): - """Set name in upper case.""" - self.name = name.upper() diff --git a/examples_old/inspect.py b/examples_old/inspect.py deleted file mode 100644 index 27a6483f..00000000 --- a/examples_old/inspect.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any - - -def fs( - x, - i: int, - l: list[str], - t: tuple[int, str], - d: dict[str, Any], - s: str = "d", -) -> list[str]: - return ["x"] diff --git a/examples_old/link/__init__.py b/examples_old/link/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples_old/link/fullname.py b/examples_old/link/fullname.py deleted file mode 100644 index fb2e5496..00000000 --- a/examples_old/link/fullname.py +++ /dev/null @@ -1,6 +0,0 @@ -def func(): - """Internal link example. - - See Also: - [a method](mkapi.core.base.Item.set_html) - """ diff --git a/examples_old/link/qualname.py b/examples_old/link/qualname.py deleted file mode 100644 index 13954549..00000000 --- a/examples_old/link/qualname.py +++ /dev/null @@ -1,14 +0,0 @@ -from mkapi.core.base import Section -from mkapi.core.docstring import get_docstring - - -def func(): - """Internal link examples. - - * [Section]() --- Imported object. - * [](get_docstring) --- Imported object. - * [Section.set_html]() --- Member of imported object. - * [Section definition](Section) --- Alternative text. - * Section_ --- reStructuredText style. - """ - return Section(), get_docstring(None) diff --git a/examples_old/meta.py b/examples_old/meta.py deleted file mode 100644 index f2d0e272..00000000 --- a/examples_old/meta.py +++ /dev/null @@ -1,35 +0,0 @@ -class A(type): - """A""" - - def f(cls): - """f""" - - -class B(A): - """B""" - - def g(self, x): - """g - - Args: - x (int): parameter. - """ - - -class C(B): - """C""" - - def g(self, x): - pass - - -class D: - """D""" - - -class E(D): - """E""" - - -class F(E): - """F""" diff --git a/examples_old/mkdocs.yml b/examples_old/mkdocs.yml deleted file mode 100644 index 7e8b14e6..00000000 --- a/examples_old/mkdocs.yml +++ /dev/null @@ -1,36 +0,0 @@ -site_name: Doc for CI -site_url: https://daizutabi.github.io/mkapi/ -site_description: API documentation with MkDocs. -site_author: daizutabi -repo_url: https://github.com/daizutabi/mkapi/ -edit_uri: "" -theme: - name: mkdocs - highlightjs: true - hljs_languages: - - yaml -plugins: - - mkapi: - src_dirs: [.] - on_config: custom.on_config - filters: [plugin_filter] - exclude: [.tests] - page_title: custom.page_title - section_title: custom.section_title - -nav: - - index.md - - /mkapi.objects|nav_filter1|nav_filter2 - - Section: - - 1.md - - /mkapi|nav_filter3 - - 2.md - - API: /mkdocs|nav_filter4 - - -extra_css: - - custom.css -extra_javascript: - - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js -markdown_extensions: - - pymdownx.arithmatex \ No newline at end of file diff --git a/examples_old/styles/__init__.py b/examples_old/styles/__init__.py deleted file mode 100644 index 5e75222f..00000000 --- a/examples_old/styles/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""" -http://ja.dochub.org/sphinx/usage/extensions/example_google.html#example-google -http://ja.dochub.org/sphinx/usage/extensions/example_numpy.html#example-numpy -""" diff --git a/examples_old/styles/example_google.py b/examples_old/styles/example_google.py deleted file mode 100644 index 5fde6e22..00000000 --- a/examples_old/styles/example_google.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Example Google style docstrings. - -This module demonstrates documentation as specified by the `Google Python -Style Guide`_. Docstrings may extend over multiple lines. Sections are created -with a section header and a colon followed by a block of indented text. - -Example: - Examples can be given using either the ``Example`` or ``Examples`` - sections. Sections support any reStructuredText formatting, including - literal blocks:: - - $ python example_google.py - -Section breaks are created by resuming unindented text. Section breaks -are also implicitly created anytime a new section starts. - -Attributes: - module_level_variable1 (int): Module level variables may be documented in - either the ``Attributes`` section of the module docstring, or in an - inline docstring immediately following the variable. - - Either form is acceptable, but the two should not be mixed. Choose - one convention to document module level variables and be consistent - with it. - -Todo: - * For module TODOs - * You have to also use ``sphinx.ext.todo`` extension - -.. _Google Python Style Guide: - https://google.github.io/styleguide/pyguide.html - -""" - -module_level_variable1 = 12345 - -module_level_variable2 = 98765 -"""int: Module level variable documented inline. - -The docstring may span multiple lines. The type may optionally be specified -on the first line, separated by a colon. -""" - - -def function_with_types_in_docstring(param1, param2): - """Example function with types documented in the docstring. - - `PEP 484`_ type annotations are supported. If attribute, parameter, and - return types are annotated according to `PEP 484`_, they do not need to be - included in the docstring: - - Args: - param1 (int): The first parameter. - param2 (str): The second parameter. - - Returns: - bool: The return value. True for success, False otherwise. - - .. _PEP 484: - https://www.python.org/dev/peps/pep-0484/ - - """ - - -def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: - """Example function with PEP 484 type annotations. - - Args: - param1: The first parameter. - param2: The second parameter. - - Returns: - The return value. True for success, False otherwise. - - """ - - -def module_level_function(param1, param2=None, *args, **kwargs): - """This is an example of a module level function. - - Function parameters should be documented in the ``Args`` section. The name - of each parameter is required. The type and description of each parameter - is optional, but should be included if not obvious. - - If ``*args`` or ``**kwargs`` are accepted, - they should be listed as ``*args`` and ``**kwargs``. - - The format for a parameter is:: - - name (type): description - The description may span multiple lines. Following - lines should be indented. The "(type)" is optional. - - Multiple paragraphs are supported in parameter - descriptions. - - Args: - param1 (int): The first parameter. - param2 (:obj:`str`, optional): The second parameter. Defaults to None. - Second line of description should be indented. - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - - Returns: - bool: True if successful, False otherwise. - - The return type is optional and may be specified at the beginning of - the ``Returns`` section followed by a colon. - - The ``Returns`` section may span multiple lines and paragraphs. - Following lines should be indented to match the first line. - - The ``Returns`` section supports any reStructuredText formatting, - including literal blocks:: - - { - 'param1': param1, - 'param2': param2 - } - - Raises: - AttributeError: The ``Raises`` section is a list of all exceptions - that are relevant to the interface. - ValueError: If `param2` is equal to `param1`. - - """ - if param1 == param2: - raise ValueError('param1 may not be equal to param2') - return True - - -def example_generator(n): - """Generators have a ``Yields`` section instead of a ``Returns`` section. - - Args: - n (int): The upper limit of the range to generate, from 0 to `n` - 1. - - Yields: - int: The next number in the range of 0 to `n` - 1. - - Examples: - Examples should be written in doctest format, and should illustrate how - to use the function. - - >>> print([i for i in example_generator(4)]) - [0, 1, 2, 3] - - """ - for i in range(n): - yield i - - -class ExampleError(Exception): - """Exceptions are documented in the same way as classes. - - The __init__ method may be documented in either the class level - docstring, or as a docstring on the __init__ method itself. - - Either form is acceptable, but the two should not be mixed. Choose one - convention to document the __init__ method and be consistent with it. - - Note: - Do not include the `self` parameter in the ``Args`` section. - - Args: - msg (str): Human readable string describing the exception. - code (:obj:`int`, optional): Error code. - - Attributes: - msg (str): Human readable string describing the exception. - code (int): Exception error code. - - """ - - def __init__(self, msg, code): - self.msg = msg - self.code = code - - -class ExampleClass: - """The summary line for a class docstring should fit on one line. - - If the class has public attributes, they may be documented here - in an ``Attributes`` section and follow the same formatting as a - function's ``Args`` section. Alternatively, attributes may be documented - inline with the attribute's declaration (see __init__ method below). - - Properties created with the ``@property`` decorator should be documented - in the property's getter method. - - Attributes: - attr1 (str): Description of `attr1`. - attr2 (:obj:`int`, optional): Description of `attr2`. - - """ - - def __init__(self, param1, param2, param3): - """Example of docstring on the __init__ method. - - The __init__ method may be documented in either the class level - docstring, or as a docstring on the __init__ method itself. - - Either form is acceptable, but the two should not be mixed. Choose one - convention to document the __init__ method and be consistent with it. - - Note: - Do not include the `self` parameter in the ``Args`` section. - - Args: - param1 (str): Description of `param1`. - param2 (:obj:`int`, optional): Description of `param2`. Multiple - lines are supported. - param3 (list(str)): Description of `param3`. - - """ - self.attr1 = param1 - self.attr2 = param2 - self.attr3 = param3 #: Doc comment *inline* with attribute - - #: list(str): Doc comment *before* attribute, with type specified - self.attr4 = ['attr4'] - - self.attr5 = None - """str: Docstring *after* attribute, with type specified.""" - - @property - def readonly_property(self): - """str: Properties should be documented in their getter method.""" - return 'readonly_property' - - @property - def readwrite_property(self): - """list(str): Properties with both a getter and setter - should only be documented in their getter method. - - If the setter method contains notable behavior, it should be - mentioned here. - """ - return ['readwrite_property'] - - @readwrite_property.setter - def readwrite_property(self, value): - value - - def example_method(self, param1, param2): - """Class methods are similar to regular functions. - - Note: - Do not include the `self` parameter in the ``Args`` section. - - Args: - param1: The first parameter. - param2: The second parameter. - - Returns: - True if successful, False otherwise. - - """ - return True - - def __special__(self): - """By default special members with docstrings are not included. - - Special members are any methods or attributes that start with and - end with a double underscore. Any special member with a docstring - will be included in the output, if - ``napoleon_include_special_with_doc`` is set to True. - - This behavior can be enabled by changing the following setting in - Sphinx's conf.py:: - - napoleon_include_special_with_doc = True - - """ - pass - - def __special_without_docstring__(self): - pass - - def _private(self): - """By default private members are not included. - - Private members are any methods or attributes that start with an - underscore and are *not* special. By default they are not included - in the output. - - This behavior can be changed such that private members *are* included - by changing the following setting in Sphinx's conf.py:: - - napoleon_include_private_with_doc = True - - """ - pass - - def _private_without_docstring(self): - pass - -class ExamplePEP526Class: - """The summary line for a class docstring should fit on one line. - - If the class has public attributes, they may be documented here - in an ``Attributes`` section and follow the same formatting as a - function's ``Args`` section. If ``napoleon_attr_annotations`` - is True, types can be specified in the class body using ``PEP 526`` - annotations. - - Attributes: - attr1: Description of `attr1`. - attr2: Description of `attr2`. - - """ - - attr1: str - attr2: int \ No newline at end of file diff --git a/examples_old/styles/example_numpy.py b/examples_old/styles/example_numpy.py deleted file mode 100644 index 2712447f..00000000 --- a/examples_old/styles/example_numpy.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Example NumPy style docstrings. - -This module demonstrates documentation as specified by the `NumPy -Documentation HOWTO`_. Docstrings may extend over multiple lines. Sections -are created with a section header followed by an underline of equal length. - -Example -------- -Examples can be given using either the ``Example`` or ``Examples`` -sections. Sections support any reStructuredText formatting, including -literal blocks:: - - $ python example_numpy.py - - -Section breaks are created with two blank lines. Section breaks are also -implicitly created anytime a new section starts. Section bodies *may* be -indented: - -Notes ------ - This is an example of an indented section. It's like any other section, - but the body is indented to help it stand out from surrounding text. - -If a section is indented, then a section break is created by -resuming unindented text. - -Attributes ----------- -module_level_variable1 : int - Module level variables may be documented in either the ``Attributes`` - section of the module docstring, or in an inline docstring immediately - following the variable. - - Either form is acceptable, but the two should not be mixed. Choose - one convention to document module level variables and be consistent - with it. - - -.. _NumPy Documentation HOWTO: - https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt - -""" - -module_level_variable1 = 12345 - -module_level_variable2 = 98765 -"""int: Module level variable documented inline. - -The docstring may span multiple lines. The type may optionally be specified -on the first line, separated by a colon. -""" - - -def function_with_types_in_docstring(param1, param2): - """Example function with types documented in the docstring. - - `PEP 484`_ type annotations are supported. If attribute, parameter, and - return types are annotated according to `PEP 484`_, they do not need to be - included in the docstring: - - Parameters - ---------- - param1 : int - The first parameter. - param2 : str - The second parameter. - - Returns - ------- - bool - True if successful, False otherwise. - - .. _PEP 484: - https://www.python.org/dev/peps/pep-0484/ - - """ - - -def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: - """Example function with PEP 484 type annotations. - - The return type must be duplicated in the docstring to comply - with the NumPy docstring style. - - Parameters - ---------- - param1 - The first parameter. - param2 - The second parameter. - - Returns - ------- - bool - True if successful, False otherwise. - - """ - - -def module_level_function(param1, param2=None, *args, **kwargs): - """This is an example of a module level function. - - Function parameters should be documented in the ``Parameters`` section. - The name of each parameter is required. The type and description of each - parameter is optional, but should be included if not obvious. - - If ``*args`` or ``**kwargs`` are accepted, - they should be listed as ``*args`` and ``**kwargs``. - - The format for a parameter is:: - - name : type - description - - The description may span multiple lines. Following lines - should be indented to match the first line of the description. - The ": type" is optional. - - Multiple paragraphs are supported in parameter - descriptions. - - Parameters - ---------- - param1 : int - The first parameter. - param2 : :obj:`str`, optional - The second parameter. - *args - Variable length argument list. - **kwargs - Arbitrary keyword arguments. - - Returns - ------- - bool - True if successful, False otherwise. - - The return type is not optional. The ``Returns`` section may span - multiple lines and paragraphs. Following lines should be indented to - match the first line of the description. - - The ``Returns`` section supports any reStructuredText formatting, - including literal blocks:: - - { - 'param1': param1, - 'param2': param2 - } - - Raises - ------ - AttributeError - The ``Raises`` section is a list of all exceptions - that are relevant to the interface. - ValueError - If `param2` is equal to `param1`. - - """ - if param1 == param2: - raise ValueError('param1 may not be equal to param2') - return True - - -def example_generator(n): - """Generators have a ``Yields`` section instead of a ``Returns`` section. - - Parameters - ---------- - n : int - The upper limit of the range to generate, from 0 to `n` - 1. - - Yields - ------ - int - The next number in the range of 0 to `n` - 1. - - Examples - -------- - Examples should be written in doctest format, and should illustrate how - to use the function. - - >>> print([i for i in example_generator(4)]) - [0, 1, 2, 3] - - """ - for i in range(n): - yield i - - -class ExampleError(Exception): - """Exceptions are documented in the same way as classes. - - The __init__ method may be documented in either the class level - docstring, or as a docstring on the __init__ method itself. - - Either form is acceptable, but the two should not be mixed. Choose one - convention to document the __init__ method and be consistent with it. - - Note - ---- - Do not include the `self` parameter in the ``Parameters`` section. - - Parameters - ---------- - msg : str - Human readable string describing the exception. - code : :obj:`int`, optional - Numeric error code. - - Attributes - ---------- - msg : str - Human readable string describing the exception. - code : int - Numeric error code. - - """ - - def __init__(self, msg, code): - self.msg = msg - self.code = code - - -class ExampleClass: - """The summary line for a class docstring should fit on one line. - - If the class has public attributes, they may be documented here - in an ``Attributes`` section and follow the same formatting as a - function's ``Args`` section. Alternatively, attributes may be documented - inline with the attribute's declaration (see __init__ method below). - - Properties created with the ``@property`` decorator should be documented - in the property's getter method. - - Attributes - ---------- - attr1 : str - Description of `attr1`. - attr2 : :obj:`int`, optional - Description of `attr2`. - - """ - - def __init__(self, param1, param2, param3): - """Example of docstring on the __init__ method. - - The __init__ method may be documented in either the class level - docstring, or as a docstring on the __init__ method itself. - - Either form is acceptable, but the two should not be mixed. Choose one - convention to document the __init__ method and be consistent with it. - - Note - ---- - Do not include the `self` parameter in the ``Parameters`` section. - - Parameters - ---------- - param1 : str - Description of `param1`. - param2 : list(str) - Description of `param2`. Multiple - lines are supported. - param3 : :obj:`int`, optional - Description of `param3`. - - """ - self.attr1 = param1 - self.attr2 = param2 - self.attr3 = param3 #: Doc comment *inline* with attribute - - #: list(str): Doc comment *before* attribute, with type specified - self.attr4 = ["attr4"] - - self.attr5 = None - """str: Docstring *after* attribute, with type specified.""" - - @property - def readonly_property(self): - """str: Properties should be documented in their getter method.""" - return "readonly_property" - - @property - def readwrite_property(self): - """list(str): Properties with both a getter and setter - should only be documented in their getter method. - - If the setter method contains notable behavior, it should be - mentioned here. - """ - return ["readwrite_property"] - - @readwrite_property.setter - def readwrite_property(self, value): - value - - def example_method(self, param1, param2): - """Class methods are similar to regular functions. - - Note - ---- - Do not include the `self` parameter in the ``Parameters`` section. - - Parameters - ---------- - param1 - The first parameter. - param2 - The second parameter. - - Returns - ------- - bool - True if successful, False otherwise. - - """ - return True - - def __special__(self): - """By default special members with docstrings are not included. - - Special members are any methods or attributes that start with and - end with a double underscore. Any special member with a docstring - will be included in the output, if - ``napoleon_include_special_with_doc`` is set to True. - - This behavior can be enabled by changing the following setting in - Sphinx's conf.py:: - - napoleon_include_special_with_doc = True - - """ - pass - - def __special_without_docstring__(self): - pass - - def _private(self): - """By default private members are not included. - - Private members are any methods or attributes that start with an - underscore and are *not* special. By default they are not included - in the output. - - This behavior can be changed such that private members *are* included - by changing the following setting in Sphinx's conf.py:: - - napoleon_include_private_with_doc = True - - """ - pass - - def _private_without_docstring(self): - pass diff --git a/src/mkapi/converter.py b/src/mkapi/converter.py deleted file mode 100644 index a3b1e249..00000000 --- a/src/mkapi/converter.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Converter.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -import mkapi.ast -from mkapi.objects import load_module - -if TYPE_CHECKING: - from mkapi.objects import Module - -# from mkapi.renderer import renderer - - -def convert_module(name: str, filters: list[str]) -> str: - """Convert the [Module] instance to markdown text.""" - if module := load_module(name): - # return renderer.render_module(module) - return f"{module}: {id(module)}" - return f"{name} not found" - - -def convert_object(name: str, level: int) -> str: - return "# ac" - - -def convert_html(name: str, html: str, filters: list[str]) -> str: - return f"xxxx {html}" diff --git a/src/mkapi/converters.py b/src/mkapi/converters.py new file mode 100644 index 00000000..2acb66b8 --- /dev/null +++ b/src/mkapi/converters.py @@ -0,0 +1,83 @@ +"""Converter.""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import mkapi.ast + +# from mkapi.converter import convert_html, convert_object +from mkapi.link import resolve_link +from mkapi.objects import load_module +from mkapi.utils import split_filters, update_filters + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from mkapi.objects import Module + + +OBJECT_PATTERN = re.compile(r"^(#*) *?::: (.+?)$", re.MULTILINE) + + +def _iter_markdown(source: str) -> Iterator[tuple[str, int, list[str]]]: + """Yield tuples of (text, level, filters).""" + cursor = 0 + for match in OBJECT_PATTERN.finditer(source): + start, end = match.start(), match.end() + if cursor < start and (markdown := source[cursor:start].strip()): + yield markdown, -1, [] + cursor = end + heading, name = match.groups() + level = len(heading) + name, filters = split_filters(name) + yield name, level, filters + if cursor < len(source) and (markdown := source[cursor:].strip()): + yield markdown, -1, [] + + +def convert_markdown( + source: str, + callback: Callable[[str, int, list[str]], str], +) -> str: + """Return a converted markdown.""" + index, texts = 0, [] + for name, level, filters in _iter_markdown(source): + if level == -1: + texts.append(name) + else: + text = callback(name, level, filters) + texts.append(f"\n{text}\n") + index += 1 + return "\n\n".join(texts) + + +pattern = r"\n(.*?)\n" +NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) + + +def convert_html(source: str, callback: Callable[[int, str], str]) -> str: + """Return modified HTML.""" + + def replace(match: re.Match) -> str: + index, html = match.groups() + return callback(int(index), html) + + return re.sub(NODE_PATTERN, replace, source) + + +def convert_module(name: str, filters: list[str]) -> str: + """Convert the [Module] instance to markdown text.""" + if module := load_module(name): + # return renderer.render_module(module) + return f"{module}: {id(module)}" + return f"{name} not found" + + +def convert_object(name: str, level: int) -> str: + return "# ac" + + +def convert_html(name: str, html: str, filters: list[str]) -> str: + return f"xxxx {html}" diff --git a/src/mkapi/link.py b/src/mkapi/link.py index 7ca7adc7..c1d529a4 100644 --- a/src/mkapi/link.py +++ b/src/mkapi/link.py @@ -25,8 +25,10 @@ def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] >>> resolve_link('[abc][b.c.d]', abs_src_path, abs_api_paths) '[abc](../../api/b.c#b.c.d)' - >>> resolve_link('[abc][!b.c.d]', abs_src_path, abs_api_paths) + >>> resolve_link('[abc][__mkapi__.b.c.d]', abs_src_path, abs_api_paths) '[abc](../../api/b.c#b.c.d)' + >>> resolve_link('list[[abc][__mkapi__.b.c.d]]', abs_src_path, abs_api_paths) + 'list[[abc](../../api/b.c#b.c.d)]' """ def replace(match: re.Match) -> str: @@ -35,8 +37,8 @@ def replace(match: re.Match) -> str: href = href[2:] return f"[{name}]({href})" from_mkapi = False - if href.startswith("!"): - href = href[1:] + if href.startswith("__mkapi__."): + href = href[10:] from_mkapi = True if href := _resolve_href(href, abs_src_path, abs_api_paths): @@ -121,7 +123,7 @@ def _match_last(name: str, abs_api_paths: list[str]) -> str: # self.context = {"href": [], "heading_id": ""} # super().feed(html) # href = self.context["href"] -# if len(href) == 2: # noqa: PLR2004 +# if len(href) == 2: # prefix_url, name_url = href # elif len(href) == 1: # prefix_url, name_url = "", href[0] diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 761eddb1..aba2db96 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -4,6 +4,7 @@ import ast import importlib import importlib.util +import re from dataclasses import InitVar, dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -183,6 +184,10 @@ def iter_members(self) -> Iterator[Member]: """Yield [Member] instances.""" yield from [] + @property + def id(self) -> str: # noqa: A003, D102 + return self.fullname + @dataclass(repr=False) class Attribute(Member): @@ -236,7 +241,7 @@ def get_parameter(self, name: str) -> Parameter | None: return get_by_name(self.parameters, name) def get_raise(self, name: str) -> Raise | None: - """Return a [Riase] instance by the name.""" + """Return a [Raise] instance by the name.""" return get_by_name(self.raises, name) @@ -318,7 +323,7 @@ def create_class(node: ast.ClassDef) -> Class: def create_members( node: ast.ClassDef | ast.Module, ) -> Iterator[Import | Attribute | Class | Function]: - """Yield members.""" + """Yield created members.""" for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.FunctionDef): # noqa: SIM102 if mkapi.ast.is_property(child.decorator_list): @@ -425,16 +430,31 @@ def get_source(self, maxline: int | None = None) -> str | None: return None return "\n".join(self.source.split("\n")[:maxline]) - def _get_link(self, name: str) -> str: - fullname = self.get_fullname(name) - if fullname: - return f"[{name}][__mkapi__.{fullname}]" - return name - def set_markdown(self) -> None: """Set markdown with link form.""" for type_ in self.types: - type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link) + type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) + for text in self.texts: + text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) + + def _get_link_type(self, name: str) -> str: + if fullname := self.get_fullname(name): + return get_link(name, fullname) + return name + + def _get_link_text(self, match: re.Match) -> str: + name = match.group(1) + if fullname := self.get_fullname(name): + return get_link(name, fullname) + return match.group() + + +LINK_PATTERN = re.compile(r"(? str: + """Return a markdown link.""" + return f"[{name}][__mkapi__.{fullname}]" def get_module_path(name: str) -> Path | None: @@ -469,7 +489,7 @@ def load_module(name: str) -> Module | None: def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: - """Return a [Module] instance from source string.""" + """Return a [Module] instance from a source string.""" node = ast.parse(source) module = create_module_from_node(node, name) module.source = source @@ -477,7 +497,7 @@ def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: - """Return a [Module] instance from the [ast.Module] node.""" + """Return a [Module] instance from an [ast.Module] node.""" text = ast.get_docstring(node) module = Module(node, name, text, None) if Module.current is not None: @@ -486,12 +506,13 @@ def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module for member in create_members(node): module.add_member(member) Module.current = None + module.set_markdown() _postprocess(module) return module def get_object(fullname: str) -> Module | Class | Function | Attribute | None: - """Return a [Object] instance by the fullname.""" + """Return an [Object] instance by the fullname.""" if fullname in objects: return objects[fullname] for modulename in iter_parent_modulenames(fullname): diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index f0ad23c8..c333e4b8 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -5,17 +5,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from mkapi.converter import convert_html, convert_object +# from mkapi.converter import convert_html, convert_object from mkapi.link import resolve_link from mkapi.utils import split_filters, update_filters if TYPE_CHECKING: - from collections.abc import Iterator - - -MKAPI_PATTERN = re.compile(r"^(#*) *?!\[mkapi\]\((.+?)\)$", re.MULTILINE) -pattern = r"(.*?)" -OBJECT_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) + from collections.abc import Callable, Iterator @dataclass(repr=False) @@ -23,7 +18,7 @@ class Page: """Page class works with [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). Args: - source (str): Markdown source. + source: Markdown source. abs_src_path: Absolute source path of Markdown. abs_api_paths: A list of API paths. @@ -61,24 +56,3 @@ def _iter_markdown(self) -> Iterator[str]: yield wrap_markdown(name, markdown, filters) if cursor < len(self.source) and (markdown := self.source[cursor:].strip()): yield self._resolve_link(markdown) - - def convert_html(self, html: str) -> str: - """Return modified HTML to [MkAPIPlugin][mkapi.plugins.MkAPIPlugin]. - - Args: - html: Input HTML converted by MkDocs. - """ - - def replace(match: re.Match) -> str: - name = match.group(1) - filters = match.group(2).split("|") - html = match.group(3) - return convert_html(name, html, filters) - - return re.sub(OBJECT_PATTERN, replace, html) - - -def wrap_markdown(name: str, markdown: str, filters: list[str] | None = None) -> str: - """Return Markdown text with marker for object.""" - fs = "|".join(filters) if filters else "" - return f"\n\n{markdown}\n\n" diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index f62783c6..7d0822e7 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -20,17 +20,17 @@ from mkdocs.plugins import BasePlugin from mkdocs.structure.files import Files, get_files -# from mkdocs.structure.nav import Navigation -# from mkdocs.utils.templates import TemplateContext import mkapi -from mkapi import converter +from mkapi import renderers from mkapi.utils import find_submodulenames, is_package, split_filters, update_filters if TYPE_CHECKING: from collections.abc import Callable + from mkdocs.structure.nav import Navigation from mkdocs.structure.pages import Page as MkDocsPage from mkdocs.structure.toc import AnchorLink, TableOfContents + from mkdocs.utils.templates import TemplateContext from mkapi.pages import Page as MkAPIPage @@ -57,6 +57,7 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: _insert_sys_path(self.config) + _update_templates(config, self) _update_config(config, self) if "admonition" not in config.markdown_extensions: config.markdown_extensions.append("admonition") @@ -109,34 +110,47 @@ def on_page_content(self, html: str, page: MkDocsPage, **kwargs) -> str: # if page.title: # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore mkapi_page: MkAPIPage = self.config.pages[page.file.abs_src_path] + return mkapi_page.convert_html(html) - # def on_page_context( - # self, - # context: TemplateContext, - # page: MkDocsPage, - # config: MkDocsConfig, - # nav: Navigation, - # **kwargs, - # ) -> TemplateContext: - # """Clear prefix in toc.""" - # abs_src_path = page.file.abs_src_path - # if abs_src_path in self.config.abs_api_paths: - # clear_prefix(page.toc, 2) - # else: - # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - # for level, id_ in mkapi_page.headings: - # clear_prefix(page.toc, level, id_) - # return context + def on_page_context( + self, + context: TemplateContext, + page: MkDocsPage, + config: MkDocsConfig, + nav: Navigation, + **kwargs, + ) -> TemplateContext: + """Clear prefix in toc.""" + abs_src_path = page.file.abs_src_path + if abs_src_path in self.config.abs_api_paths: + pass + # clear_prefix(page.toc, 2) + else: + mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + for level, id_ in mkapi_page.headings: + pass + # clear_prefix(page.toc, level, id_) + return context def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): - for path in ["themes", "templates"]: + for path in ["themes"]: path_str = (Path(mkapi.__file__).parent / path).as_posix() server.watch(path_str, builder) self.__class__.server = server return server +def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: + if func := _get_function(plugin, "on_config"): + msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" + logger.info(msg) + config_ = func(config, plugin) + if isinstance(config_, MkDocsConfig): + return config_ + return config + + def _insert_sys_path(config: MkAPIConfig) -> None: config_dir = Path(config.config_file_path).parent for src_dir in config.src_dirs: @@ -159,6 +173,10 @@ def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: config.nav = CACHE_CONFIG["nav"] +def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + renderers.load_templates() + + def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: nav_ = [] for item in nav: @@ -273,18 +291,8 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: def _create_page(name: str, path: Path, filters: list[str] | None = None) -> None: - with path.open("w") as f: - f.write(converter.convert_module(name, filters or [])) - - -def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: - if func := _get_function(plugin, "on_config"): - msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" - logger.info(msg) - config_ = func(config, plugin) - if isinstance(config_, MkDocsConfig): - return config_ - return config + with path.open("w") as file: + file.write(renderers.render_module(name, filters)) def _clear_prefix( diff --git a/src/mkapi/renderer.py b/src/mkapi/renderers.py similarity index 81% rename from src/mkapi/renderer.py rename to src/mkapi/renderers.py index b3e20ca0..819f7679 100644 --- a/src/mkapi/renderer.py +++ b/src/mkapi/renderers.py @@ -6,7 +6,46 @@ from jinja2 import Environment, FileSystemLoader, Template, select_autoescape import mkapi -from mkapi.objects import Module +from mkapi.objects import Module, load_module + +templates: dict[str, Template] = {} + + +def load_templates(path: Path | None = None) -> None: + """Load templates.""" + if not path: + path = Path(mkapi.__file__).parent / "templates" + loader = FileSystemLoader(path) + env = Environment(loader=loader, autoescape=select_autoescape(["jinja2"])) + for name in os.listdir(path): + templates[Path(name).stem] = env.get_template(name) + + +def render_module(name: str, filters: list[str] | None = None) -> str: + """Return a rendered Markdown for Module. + + Args: + name: Module name. + filters: A list of filters. Avaiable filters: `inherit`, `strict`, + `heading`. + + Note: + This function returns Markdown instead of HTML. The returned Markdown + will be converted into HTML by MkDocs. Then the HTML is rendered into HTML + again by other functions in this module. + """ + if not (module := load_module(name)): + return f"{name} not found" + # module_filter = object_filter = "" + # if filters: + # object_filter = "|" + "|".join(filters) + # template = self.templates["module"] + return templates["module"].render( + module=module, + # module_filter=module_filter, + # object_filter=object_filter, + ) + # return f"{module}: {id(module)}" @dataclass diff --git a/src/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 index f2a9ea4d..745e0221 100644 --- a/src/mkapi/templates/module.jinja2 +++ b/src/mkapi/templates/module.jinja2 @@ -1,17 +1,21 @@ -# ![mkapi]({{ module.name }}{{ module_filter }}|plain|link|sourcelink) - -{% if module.kind == 'module' -%} -{% for member in module.members -%} -{% if 'noheading' in object_filter -%} -![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) -{% else -%} -## ![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) -{% endif -%} -{% endfor -%} -{% else -%} -{% for member in module.members -%} -{% if member.docstring -%} -## ![mkapi]({{ member.id }}{{ module_filter }}|plain|apilink|sourcelink) -{% endif -%} -{% endfor -%} -{% endif -%} +# ::: {{ module.name }} + +{{ module.kind }} + +## Attributes + +{% for attr in module.attributes -%} +### ::: {{ attr.fullname }} +{% endfor -%} + +## Classes + +{% for cls in module.classes -%} +### ::: {{ cls.fullname }} +{% endfor -%} + +## Functions + +{% for func in module.functions -%} +### ::: {{ func.fullname }} +{% endfor -%} \ No newline at end of file diff --git a/src/mkapi/templates/module_old.jinja2 b/src/mkapi/templates/module_old.jinja2 new file mode 100644 index 00000000..f2a9ea4d --- /dev/null +++ b/src/mkapi/templates/module_old.jinja2 @@ -0,0 +1,17 @@ +# ![mkapi]({{ module.name }}{{ module_filter }}|plain|link|sourcelink) + +{% if module.kind == 'module' -%} +{% for member in module.members -%} +{% if 'noheading' in object_filter -%} +![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) +{% else -%} +## ![mkapi]({{ member.id }}{{ object_filter }}|link|sourcelink) +{% endif -%} +{% endfor -%} +{% else -%} +{% for member in module.members -%} +{% if member.docstring -%} +## ![mkapi]({{ member.id }}{{ module_filter }}|plain|apilink|sourcelink) +{% endif -%} +{% endfor -%} +{% endif -%} diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 7526ce6d..8a3a64d3 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -1,10 +1,11 @@ -from mkapi.objects import load_module +import re + +from mkapi.objects import LINK_PATTERN, load_module def test_set_markdown_objects(): module = load_module("mkapi.objects") assert module - module.set_markdown() x = [t.markdown for t in module.types] assert "list[[Item][__mkapi__.mkapi.docstrings.Item]]" in x assert "[Path][__mkapi__.pathlib.Path] | None" in x @@ -15,7 +16,6 @@ def test_set_markdown_objects(): def test_set_markdown_plugins(): module = load_module("mkapi.plugins") assert module - module.set_markdown() x = [t.markdown for t in module.types] assert "[MkDocsConfig][__mkapi__.mkdocs.config.defaults.MkDocsConfig]" in x assert "[MkDocsPage][__mkapi__.mkdocs.structure.pages.Page]" in x @@ -33,7 +33,6 @@ def test_set_markdown_bases(): cls = cls.bases[0] module = cls.module assert module - module.set_markdown() x = [t.markdown for t in module.types] assert "[Config][__mkapi__.mkdocs.config.base.Config]" in x assert "[T][__mkapi__.mkdocs.config.base.T]" in x @@ -42,3 +41,32 @@ def test_set_markdown_bases(): assert "[PlainConfigSchema][__mkapi__.mkdocs.config.base.PlainConfigSchema]" in x assert "str | [IO][__mkapi__.typing.IO] | None" in x assert "[Iterator][__mkapi__.typing.Iterator][[IO][__mkapi__.typing.IO]]" in x + + +def test_link_pattern(): + def f(m: re.Match) -> str: + name = m.group(1) + if name == "abc": + return f"[{name}][_{name}]" + return m.group() + + assert re.search(LINK_PATTERN, "X[abc]Y") + assert not re.search(LINK_PATTERN, "X[ab c]Y") + assert re.search(LINK_PATTERN, "X[abc][]Y") + assert not re.search(LINK_PATTERN, "X[abc](xyz)Y") + assert not re.search(LINK_PATTERN, "X[abc][xyz]Y") + assert re.sub(LINK_PATTERN, f, "X[abc]Y") == "X[abc][_abc]Y" + assert re.sub(LINK_PATTERN, f, "X[abc[abc]]Y") == "X[abc[abc][_abc]]Y" + assert re.sub(LINK_PATTERN, f, "X[ab]Y") == "X[ab]Y" + assert re.sub(LINK_PATTERN, f, "X[ab c]Y") == "X[ab c]Y" + assert re.sub(LINK_PATTERN, f, "X[abc] c]Y") == "X[abc][_abc] c]Y" + assert re.sub(LINK_PATTERN, f, "X[abc][]Y") == "X[abc][_abc]Y" + assert re.sub(LINK_PATTERN, f, "X[abc](xyz)Y") == "X[abc](xyz)Y" + assert re.sub(LINK_PATTERN, f, "X[abc][xyz]Y") == "X[abc][xyz]Y" + + +def test_set_markdown_text(): + module = load_module("mkapi.objects") + assert module + x = [t.markdown for t in module.texts] + assert "Add a [Type][__mkapi__.mkapi.objects.Type] instance." in x diff --git a/tests/pages/__init__.py b/tests/pages/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples_old/cls/__init__.py b/tests/renderers/__init__.py similarity index 100% rename from examples_old/cls/__init__.py rename to tests/renderers/__init__.py diff --git a/tests/test_renderer.py b/tests/renderers/test_module.py similarity index 78% rename from tests/test_renderer.py rename to tests/renderers/test_module.py index 8e03fb49..501ba9d7 100644 --- a/tests/test_renderer.py +++ b/tests/renderers/test_module.py @@ -1,5 +1,19 @@ -# from mkapi.objects import load_module -# from mkapi.renderer import renderer +import pytest + +from mkapi.objects import load_module +from mkapi.renderers import load_templates, render_module, templates + + +@pytest.fixture(scope="module") +def template(): + load_templates() + return templates["module"] + + +def test_get_templates(template): + print(template) + module = load_module("mkapi.objects") + assert module # def test_render_module(google): diff --git a/tests/renderers/test_templates.py b/tests/renderers/test_templates.py new file mode 100644 index 00000000..36a5dd62 --- /dev/null +++ b/tests/renderers/test_templates.py @@ -0,0 +1,39 @@ +from mkapi.renderers import load_templates, templates + + +def test_load_templates(): + load_templates() + assert "bases" in templates + assert "code" in templates + assert "docstring" in templates + assert "items" in templates + assert "macros" in templates + assert "member" in templates + assert "node" in templates + assert "object" in templates + + +# def test_render_module(google): +# markdown = renderer.render_module(google) +# assert "# ![mkapi](examples.styles.example_google" in markdown +# assert "## ![mkapi](examples.styles.example_google.ExampleClass" in markdown +# assert "## ![mkapi](examples.styles.example_google.example_generator" in markdown + + +# def test_module_empty_filters(): +# module = load_module("mkapi.core.base") +# m = renderer.render_module(module).split("\n") +# assert m[0] == "# ![mkapi](mkapi.core.base|plain|link|sourcelink)" +# assert m[2] == "## ![mkapi](mkapi.core.base.Base||link|sourcelink)" +# assert m[3] == "## ![mkapi](mkapi.core.base.Inline||link|sourcelink)" +# assert m[4] == "## ![mkapi](mkapi.core.base.Type||link|sourcelink)" +# assert m[5] == "## ![mkapi](mkapi.core.base.Item||link|sourcelink)" + + +# def test_code_empty_filters(): +# code = get_code("mkapi.core.base") +# m = renderer.render_code(code) +# assert 'mkapi.core.' in m +# assert 'base' in m +# assert '' in m +# assert 'DOCS' in m diff --git a/tests/pages/test_page.py b/tests/test_converters.py similarity index 52% rename from tests/pages/test_page.py rename to tests/test_converters.py index a344efcb..64bed4e9 100644 --- a/tests/pages/test_page.py +++ b/tests/test_converters.py @@ -1,18 +1,59 @@ from markdown import Markdown -from mkapi.pages import Page +from mkapi.pages import Page, _iter_markdown, convert_html, convert_markdown source = """ # Title +## ::: a.o.Object|a|b +text +### ::: a.s.Section +::: a.module|m +end +""" -## ![mkapi](a.o.Object|a|b) -text +def test_iter_markdown(): + x = list(_iter_markdown(source)) + assert x[0] == ("# Title", -1, []) + assert x[1] == ("a.o.Object", 2, ["a", "b"]) + assert x[2] == ("text", -1, []) + assert x[3] == ("a.s.Section", 3, []) + assert x[4] == ("a.module", 0, ["m"]) + assert x[5] == ("end", -1, []) -### ![mkapi](a.s.Section) -end -""" +def callback_markdown(name, level, filters): + f = "|".join(filters) + return f"<{name}>[{level}]({f})" + + +def test_convert_markdown(): + x = convert_markdown(source, callback_markdown) + assert "# Title\n\n" in x + assert "\n[2](a|b)\n\n\n" in x + assert "\n\ntext\n\n" in x + assert "\n[3]()\n\n\n" in x + assert "\n[0](m)\n\n\n" in x + + +def callback_html(index, html): + return f"{index}{html[:10]}" + + +def test_convert_html(): + markdown = convert_markdown(source, callback_markdown) + converter = Markdown() + html = converter.convert(markdown) + assert "

Title

\n" in html + assert "\n" in html + assert '

2

' in html + assert '

3

' in html + assert '

0

' in html + html = convert_html(html, callback_html) + assert "

Title

\n" in html + assert "0

\n\n" in html + assert "1

\n\n" in html + assert "2

\n\n" in html def test_page(): From 652d964c10b413dcc51d58d350d5ff190045b8f4 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 13 Jan 2024 09:48:20 +0900 Subject: [PATCH 089/148] converters -> pages --- src/mkapi/converters.py | 49 --------- src/mkapi/nodes.py | 111 ++++++-------------- src/mkapi/pages.py | 95 +++++++++++++---- tests/nodes/__init__.py | 0 tests/nodes/test_node.py | 16 --- tests/test_nodes.py | 21 ++++ tests/{test_converters.py => test_pages.py} | 2 +- 7 files changed, 125 insertions(+), 169 deletions(-) delete mode 100644 tests/nodes/__init__.py delete mode 100644 tests/nodes/test_node.py create mode 100644 tests/test_nodes.py rename tests/{test_converters.py => test_pages.py} (97%) diff --git a/src/mkapi/converters.py b/src/mkapi/converters.py index 2acb66b8..f43631d6 100644 --- a/src/mkapi/converters.py +++ b/src/mkapi/converters.py @@ -18,55 +18,6 @@ from mkapi.objects import Module -OBJECT_PATTERN = re.compile(r"^(#*) *?::: (.+?)$", re.MULTILINE) - - -def _iter_markdown(source: str) -> Iterator[tuple[str, int, list[str]]]: - """Yield tuples of (text, level, filters).""" - cursor = 0 - for match in OBJECT_PATTERN.finditer(source): - start, end = match.start(), match.end() - if cursor < start and (markdown := source[cursor:start].strip()): - yield markdown, -1, [] - cursor = end - heading, name = match.groups() - level = len(heading) - name, filters = split_filters(name) - yield name, level, filters - if cursor < len(source) and (markdown := source[cursor:].strip()): - yield markdown, -1, [] - - -def convert_markdown( - source: str, - callback: Callable[[str, int, list[str]], str], -) -> str: - """Return a converted markdown.""" - index, texts = 0, [] - for name, level, filters in _iter_markdown(source): - if level == -1: - texts.append(name) - else: - text = callback(name, level, filters) - texts.append(f"\n{text}\n") - index += 1 - return "\n\n".join(texts) - - -pattern = r"\n(.*?)\n" -NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) - - -def convert_html(source: str, callback: Callable[[int, str], str]) -> str: - """Return modified HTML.""" - - def replace(match: re.Match) -> str: - index, html = match.groups() - return callback(int(index), html) - - return re.sub(NODE_PATTERN, replace, source) - - def convert_module(name: str, filters: list[str]) -> str: """Convert the [Module] instance to markdown text.""" if module := load_module(name): diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py index e1587e68..e41b5f1f 100644 --- a/src/mkapi/nodes.py +++ b/src/mkapi/nodes.py @@ -1,7 +1,7 @@ """Node class represents Markdown and HTML structure.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING from mkapi.objects import ( @@ -9,7 +9,6 @@ Class, Function, Module, - Object, Parameter, Raise, Return, @@ -26,17 +25,17 @@ class Node: """Node class.""" name: str - object: Module | Class | Function | Attribute | Parameter | Raise | Return # noqa: A003 - kind: str - parent: Node | None - members: list[Node] | None + object: Module | Class | Function | Attribute # noqa: A003 + members: list[Node] = field(default_factory=list, init=False) + parent: Node | None = None + html: str = field(init=False) def __repr__(self) -> str: class_name = self.__class__.__name__ return f"{class_name}({self.name!r}, members={len(self)})" def __len__(self) -> int: - return len(self.members or []) + return len(self.members) def __contains__(self, name: str) -> bool: if self.members: @@ -50,89 +49,41 @@ def get_kind(self) -> str: """Returns kind of self.""" raise NotImplementedError - def get_markdown(self) -> str: - """Returns a Markdown source for docstring of self.""" - return f"<{self.name}@{id(self)}>" - def walk(self) -> Iterator[Node]: """Yields all members.""" yield self - if self.members: - for member in self.members: - yield from member.walk() + for member in self.members: + yield from member.walk() + + def get_markdown(self, level: int, filters: list[str]) -> str: + """Returns a Markdown source for docstring of self.""" + markdowns = [] + for node in self.walk(): + markdowns.append(f"{node.object.id}\n\n") # noqa: PERF401 + return "\n\n\n\n".join(markdowns) + + def convert_html(self, html: str, level: int, filters: list[str]) -> str: + htmls = html.split("") + for node, html in zip(self.walk(), htmls, strict=False): + node.html = html.strip() + return "a" def get_node(name: str) -> Node: """Return a [Node] instance from the object name.""" obj = get_object(name) - print(obj) - if not obj or not isinstance(obj, Module | Class | Function): - raise NotImplementedError - return _get_node(obj) - - -def _get_node(obj: Module | Class | Function, parent: Node | None = None) -> Node: - """Return a [Node] instance of [Module], [Class], and [Function].""" - node = Node(obj.name, obj, _get_kind(obj), parent, []) - if isinstance(obj, Module): - node.members = list(_iter_members_module(node, obj)) - elif isinstance(obj, Class): - node.members = list(_iter_members_class(node, obj)) - elif isinstance(obj, Function): - node.members = list(_iter_members_function(node, obj)) - else: + if not isinstance(obj, Module | Class | Function | Attribute): raise NotImplementedError - return node - - -def _iter_members_module(node: Node, obj: Module | Class) -> Iterator[Node]: - yield from _iter_attributes(node, obj) - yield from _iter_classes(node, obj) - yield from _iter_functions(node, obj) - - -def _iter_members_class(node: Node, obj: Class) -> Iterator[Node]: - yield from _iter_members_module(node, obj) - yield from _iter_parameters(node, obj) - yield from _iter_raises(node, obj) - # bases - + return _get_node(obj, None) -def _iter_members_function(node: Node, obj: Function) -> Iterator[Node]: - yield from _iter_parameters(node, obj) - yield from _iter_raises(node, obj) - yield from _iter_returns(node, obj) - -def _iter_attributes(node: Node, obj: Module | Class) -> Iterator[Node]: - for attr in obj.attributes: - yield Node(attr.name, attr, _get_kind(attr), node, None) - - -def _iter_parameters(node: Node, obj: Class | Function) -> Iterator[Node]: - for arg in obj.parameters: - yield Node(arg.name, arg, _get_kind(arg), node, None) - - -def _iter_raises(node: Node, obj: Class | Function) -> Iterator[Node]: - for raises in obj.raises: - yield Node(raises.name, raises, _get_kind(raises), node, None) - - -def _iter_returns(node: Node, obj: Function) -> Iterator[Node]: - rt = obj.returns - yield Node(rt.name, rt, _get_kind(rt), node, None) - - -def _iter_classes(node: Node, obj: Module | Class) -> Iterator[Node]: - for cls in obj.classes: - yield _get_node(cls, node) - - -def _iter_functions(node: Node, obj: Module | Class) -> Iterator[Node]: - for func in obj.functions: - yield _get_node(func, node) +def _get_node(obj: Module | Class | Function | Attribute, parent: Node | None) -> Node: + node = Node(obj.name, obj, parent) + if isinstance(obj, Module | Class): + node.members = list(_iter_members(node, obj)) + return node -def _get_kind(obj: Object) -> str: - return obj.__class__.__name__.lower() +def _iter_members(node: Node, obj: Module | Class) -> Iterator[Node]: + for member in obj.attributes + obj.classes + obj.functions: + yield _get_node(member, node) diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index c333e4b8..ae9a1e44 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -7,11 +7,63 @@ # from mkapi.converter import convert_html, convert_object from mkapi.link import resolve_link +from mkapi.nodes import get_node from mkapi.utils import split_filters, update_filters if TYPE_CHECKING: from collections.abc import Callable, Iterator + from mkapi.nodes import Node + + +OBJECT_PATTERN = re.compile(r"^(#*) *?::: (.+?)$", re.MULTILINE) + + +def _iter_markdown(source: str) -> Iterator[tuple[str, int, list[str]]]: + """Yield tuples of (text, level, filters).""" + cursor = 0 + for match in OBJECT_PATTERN.finditer(source): + start, end = match.start(), match.end() + if cursor < start and (markdown := source[cursor:start].strip()): + yield markdown, -1, [] + cursor = end + heading, name = match.groups() + level = len(heading) + name, filters = split_filters(name) + yield name, level, filters + if cursor < len(source) and (markdown := source[cursor:].strip()): + yield markdown, -1, [] + + +def convert_markdown( + source: str, + callback: Callable[[str, int, list[str]], str], +) -> str: + """Return a converted markdown.""" + index, texts = 0, [] + for name, level, filters in _iter_markdown(source): + if level == -1: + texts.append(name) + else: + text = callback(name, level, filters) + texts.append(f"\n{text}\n") + index += 1 + return "\n\n".join(texts) + + +pattern = r"\n(.*?)\n" +NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) + + +def convert_html(source: str, callback: Callable[[int, str], str]) -> str: + """Return modified HTML.""" + + def replace(match: re.Match) -> str: + index, html = match.groups() + return callback(int(index), html) + + return re.sub(NODE_PATTERN, replace, source) + @dataclass(repr=False) class Page: @@ -29,30 +81,27 @@ class Page: source: str abs_src_path: str - abs_api_paths: list[str] = field(default_factory=list) - filters: list[str] = field(default_factory=list) - headings: list[tuple[str, int]] = field(default_factory=list, init=False) + abs_api_paths: list[str] + nodes: list[Node] = field(default_factory=list) + levels: list[int] = field(default_factory=list) + filters: list[list[str]] = field(default_factory=list) - def convert_markdown(self) -> str: # noqa: D102 - return "\n\n".join(self._iter_markdown()) + def _callback_markdown(self, name: str, level: int, filters: list[str]) -> str: + node = get_node(name) + self.nodes.append(node) + self.levels.append(level) + self.filters.append(filters) + return node.get_markdown(level, filters) - def _resolve_link(self, markdown: str) -> str: + def convert_markdown(self) -> str: # noqa: D102 + markdown = convert_markdown(self.source, self._callback_markdown) return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) - def _iter_markdown(self) -> Iterator[str]: - cursor = 0 - for match in MKAPI_PATTERN.finditer(self.source): - start, end = match.start(), match.end() - if cursor < start and (markdown := self.source[cursor:start].strip()): - yield self._resolve_link(markdown) - cursor = end - heading, name = match.groups() - level = len(heading) - name, filters = split_filters(name) - filters = update_filters(self.filters, filters) - markdown = convert_object(name, level) # TODO: callback for link. - if level: - self.headings.append((name, level)) # duplicated name? - yield wrap_markdown(name, markdown, filters) - if cursor < len(self.source) and (markdown := self.source[cursor:].strip()): - yield self._resolve_link(markdown) + def _callback_html(self, index: int, html: str) -> str: + node = self.nodes[index] + level = self.levels[index] + filters = self.filters[index] + return node.convert_html(html, level, filters) + + def convert_html(self, html: str) -> str: # noqa: D102 + return convert_html(html, self._callback_html) diff --git a/tests/nodes/__init__.py b/tests/nodes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py deleted file mode 100644 index e7b29db8..00000000 --- a/tests/nodes/test_node.py +++ /dev/null @@ -1,16 +0,0 @@ -from mkapi.nodes import get_node - - -# def test_node(): -# node = get_node("mkdocs.plugins") -# for m in node.walk(): -# print(m) - - -# def test_property(): -# module = load_module("mkapi.objects") -# assert module -# assert module.id == "mkapi.objects" -# f = module.get_function("get_object") -# assert f -# assert f.id == "mkapi.objects.get_object" diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 00000000..250aecc6 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,21 @@ +from mkapi.nodes import get_node +from mkapi.objects import load_module, objects + + +def test_node(): + node = load_module("examples.styles.example_google") + print(objects) + node = get_node("examples.styles.example_google") + for m in node.walk(): + print(m) + print(node.object.text) + # assert 0 + + +# def test_property(): +# module = load_module("mkapi.objects") +# assert module +# assert module.id == "mkapi.objects" +# f = module.get_function("get_object") +# assert f +# assert f.id == "mkapi.objects.get_object" diff --git a/tests/test_converters.py b/tests/test_pages.py similarity index 97% rename from tests/test_converters.py rename to tests/test_pages.py index 64bed4e9..82529b56 100644 --- a/tests/test_converters.py +++ b/tests/test_pages.py @@ -1,6 +1,6 @@ from markdown import Markdown -from mkapi.pages import Page, _iter_markdown, convert_html, convert_markdown +from mkapi.pages import _iter_markdown, convert_html, convert_markdown source = """ # Title From 68bbbd0cbffadb1266964dd716a2178a1797764b Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 13 Jan 2024 16:38:54 +0900 Subject: [PATCH 090/148] Element class --- src/mkapi/ast.py | 25 ++- src/mkapi/docstrings.py | 55 +++--- src/mkapi/elements.py | 169 ++++++++++++++++++ src/mkapi/objects.py | 172 ++----------------- src/mkapi/utils.py | 14 ++ src_old/mkapi/core/__init__.py | 1 - src_old/mkapi/core/code.py | 89 ---------- src_old/mkapi/core/inherit.py | 144 ---------------- src_old/mkapi/core/module.py | 113 ------------ src_old/mkapi/core/object.py | 213 ----------------------- src_old/mkapi/core/postprocess.py | 126 -------------- src_old/mkapi/core/structure.py | 162 ------------------ tests/docstrings/conftest.py | 2 +- tests/docstrings/test_google.py | 38 ++-- tests/docstrings/test_merge.py | 24 ++- tests/docstrings/test_numpy.py | 22 +-- tests/elements/__init__.py | 0 tests/elements/conftest.py | 48 ++++++ tests/elements/test_elements.py | 146 ++++++++++++++++ tests/objects/test_object.py | 219 ++++++++++++------------ tests/test_ast.py | 16 +- tests/test_nodes.py | 24 ++- tests_old/core/test_core_code.py | 6 - tests_old/core/test_core_inherit.py | 25 --- tests_old/core/test_core_module.py | 36 ---- tests_old/core/test_core_node.py | 195 --------------------- tests_old/core/test_core_node_abc.py | 61 ------- tests_old/core/test_core_node_init.py | 74 -------- tests_old/core/test_core_object.py | 34 ---- tests_old/core/test_core_postprocess.py | 101 ----------- tests_old/core/test_mro.py | 39 ----- 31 files changed, 634 insertions(+), 1759 deletions(-) create mode 100644 src/mkapi/elements.py delete mode 100644 src_old/mkapi/core/__init__.py delete mode 100644 src_old/mkapi/core/code.py delete mode 100644 src_old/mkapi/core/inherit.py delete mode 100644 src_old/mkapi/core/module.py delete mode 100644 src_old/mkapi/core/object.py delete mode 100644 src_old/mkapi/core/postprocess.py delete mode 100644 src_old/mkapi/core/structure.py create mode 100644 tests/elements/__init__.py create mode 100644 tests/elements/conftest.py create mode 100644 tests/elements/test_elements.py delete mode 100644 tests_old/core/test_core_code.py delete mode 100644 tests_old/core/test_core_inherit.py delete mode 100644 tests_old/core/test_core_module.py delete mode 100644 tests_old/core/test_core_node.py delete mode 100644 tests_old/core/test_core_node_abc.py delete mode 100644 tests_old/core/test_core_node_init.py delete mode 100644 tests_old/core/test_core_object.py delete mode 100644 tests_old/core/test_core_postprocess.py delete mode 100644 tests_old/core/test_mro.py diff --git a/src/mkapi/ast.py b/src/mkapi/ast.py index 727eeebc..ae82a451 100644 --- a/src/mkapi/ast.py +++ b/src/mkapi/ast.py @@ -15,6 +15,7 @@ ImportFrom, Name, NodeTransformer, + Raise, TypeAlias, ) from inspect import Parameter, cleandoc @@ -31,7 +32,8 @@ type Node = Import_ | Def | Assign_ -def iter_child_nodes(node: AST) -> Iterator[Node]: # noqa: D103 +def iter_child_nodes(node: AST) -> Iterator[Node]: + """Yield child nodes.""" yield_type = Import | ImportFrom | AsyncFunctionDef | FunctionDef | ClassDef for child in (it := ast.iter_child_nodes(node)): if isinstance(child, yield_type): @@ -120,7 +122,7 @@ def _iter_defaults(node: AsyncFunctionDef | FunctionDef) -> Iterator[ast.expr | def iter_parameters( node: AsyncFunctionDef | FunctionDef, ) -> Iterator[tuple[ast.arg, _ParameterKind, ast.expr | None]]: - """Yield parameters from the function node.""" + """Yield parameters from a function node.""" it = _iter_defaults(node) for arg, kind in _iter_parameters(node): if kind in [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]: @@ -130,9 +132,22 @@ def iter_parameters( yield arg, kind, default -def is_property(decorators: list[ast.expr]) -> bool: - """Return True if one of decorators is `property`.""" - return any(ast.unparse(deco).startswith("property") for deco in decorators) +def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: + """Yield [Raise] instances from a function node.""" + names = [] + for child in ast.walk(node): + if isinstance(child, ast.Raise) and (type_ := child.exc): + if isinstance(type_, ast.Call): + type_ = type_.func + if (name := ast.unparse(type_)) not in names: + yield child + names.append(name) + + +def is_property(node: ast.FunctionDef) -> bool: + """Return True if a function is a property.""" + decos = node.decorator_list + return any(ast.unparse(deco).startswith("property") for deco in decos) # a1.b_2(c[d]) -> a1, b_2, c, d diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 604504ff..f4f0a1d2 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -1,10 +1,12 @@ """Parse docstrings.""" from __future__ import annotations +import ast import re from dataclasses import dataclass from typing import TYPE_CHECKING, Literal +from mkapi.elements import Text, Type from mkapi.utils import ( add_admonition, add_fence, @@ -16,6 +18,7 @@ if TYPE_CHECKING: from collections.abc import Iterator + type Style = Literal["google", "numpy"] @@ -24,8 +27,8 @@ class Item: """Item class for section items.""" name: str - type: str # noqa: A003 - text: str + type: Type # noqa: A003 + text: Text def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name}:{self.type})" @@ -36,7 +39,10 @@ def __repr__(self) -> str: def _iter_items(section: str) -> Iterator[str]: - """Yield items for Parameters, Attributes, Returns(?), and Raises sections.""" + """Yield items for Parameters, Attributes, Returns, or Raises sections. + + Items may include a type and/or text (description). + """ start = 0 for m in SPLIT_ITEM_PATTERN.finditer(section): yield section[start : m.start()].strip() @@ -45,6 +51,7 @@ def _iter_items(section: str) -> Iterator[str]: def _split_item_google(lines: list[str]) -> tuple[str, str, str]: + """Split an item into a tuple of (name, type, text) in the Google style.""" if m := re.match(SPLIT_NAME_TYPE_TEXT_PATTERN, lines[0]): name, type_, text = m.groups() elif ":" in lines[0]: @@ -56,6 +63,7 @@ def _split_item_google(lines: list[str]) -> tuple[str, str, str]: def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: + """Split an item into a tuple of (name, type, text) in the NumPy style.""" if ":" in lines[0]: name, type_ = lines[0].split(":", maxsplit=1) else: @@ -64,19 +72,15 @@ def _split_item_numpy(lines: list[str]) -> tuple[str, str, str]: def split_item(item: str, style: Style) -> tuple[str, str, str]: - """Return a tuple of (name, type, text).""" + """Split an item into a tuple of (name, type, text).""" lines = [line.strip() for line in item.split("\n")] if style == "google": return _split_item_google(lines) return _split_item_numpy(lines) -def iter_items( - section: str, - style: Style, - section_name: str = "Parameters", -) -> Iterator[Item]: - """Yiled a tuple of (name, type, text) of an item. +def iter_items(section: str, style: Style, section_name: str) -> Iterator[Item]: + """Yield [Item] instances. If the section name is 'Raises', the type is set by its name. """ @@ -85,7 +89,7 @@ def iter_items( name = name.replace("*", "") # *args -> args, **kwargs -> kwargs if section_name == "Raises": type_ = name - yield Item(name, type_, text) + yield Item(name, Type(ast.Constant(type_)), Text(text)) @dataclass @@ -216,16 +220,18 @@ def split_without_name(text: str, style: Style) -> tuple[str, str]: def iter_sections(doc: str, style: Style) -> Iterator[Section]: """Yield [Section] instances by splitting a docstring.""" for name, text in _iter_sections(doc, style): - type_ = text_ = "" - items = [] + type_, text_, items = Type(None), Text(None), [] if name in ["Parameters", "Attributes", "Raises"]: items = list(iter_items(text, style, name)) elif name in ["Returns", "Yields"]: type_, text_ = split_without_name(text, style) + type_ = Type(ast.Constant(type_) if type_ else None) + text_ = Text(text_ or None) + items = [Item("", type_, text_)] elif name in ["Note", "Notes", "Warning", "Warnings"]: - text_ = add_admonition(name, text) + text_ = Text(add_admonition(name, text)) else: - text_ = text + text_ = Text(text) yield Section(name, type_, text_, items) @@ -251,7 +257,16 @@ def parse(doc: str, style: Style | None = None) -> Docstring: doc = add_fence(doc) style = style or get_style(doc) sections = list(iter_sections(doc, style)) - return Docstring("", "", "", sections) + if sections and not sections[0].name and (text := sections[0].text.str): + if "\n\n" not in text: + del sections[0] + else: + text, rest = text.split("\n\n", maxsplit=1) + sections[0].text.str = rest + text = Text(text) + else: + text = Text(None) + return Docstring("", Type(None), text, sections) def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: @@ -264,8 +279,8 @@ def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: yield bi elif ai and bi: name_ = ai.name or bi.name - type_ = ai.type or bi.type - text = ai.text or bi.text + type_ = ai.type if ai.type.expr else bi.type + text = ai.text if ai.text.str else bi.text yield Item(name_, type_, text) @@ -273,8 +288,8 @@ def merge_sections(a: Section, b: Section) -> Section: """Merge two [Section] instances into one [Section] instance.""" if a.name != b.name: raise ValueError - type_ = a.type if a.type else b.type - text = f"{a.text}\n\n{b.text}".strip() + type_ = a.type if a.type.expr else b.type + text = Text(f"{a.text.str}\n\n{b.text.str}".strip()) return Section(a.name, type_, text, list(iter_merged_items(a.items, b.items))) diff --git a/src/mkapi/elements.py b/src/mkapi/elements.py new file mode 100644 index 00000000..5e404a75 --- /dev/null +++ b/src/mkapi/elements.py @@ -0,0 +1,169 @@ +"""Element module.""" +from __future__ import annotations + +import ast +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import mkapi.ast +import mkapi.dataclasses +from mkapi import docstrings +from mkapi.ast import is_property +from mkapi.utils import iter_parent_modulenames + +if TYPE_CHECKING: + from collections.abc import Iterator + from inspect import _ParameterKind + + +@dataclass +class Type: + """Type class.""" + + expr: ast.expr | None = None + markdown: str = field(default="", init=False) + + +@dataclass +class Text: + """Text class.""" + + str: str | None = None # noqa: A003 + markdown: str = field(default="", init=False) + + +@dataclass +class Element: + """Element class.""" + + name: str + node: ast.AST | None + type: Type # noqa: A003 + text: Text + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name})" + + +@dataclass(repr=False) +class Parameter(Element): + """Parameter class for [Class] or [Function].""" + + node: ast.arg | None + default: ast.expr | None + kind: _ParameterKind | None + + +def create_parameters( + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> Iterator[Parameter]: + """Yield parameters from the function node.""" + for arg, kind, default in mkapi.ast.iter_parameters(node): + type_ = Type(arg.annotation) + yield Parameter(arg.arg, arg, type_, Text(None), default, kind) + + +@dataclass(repr=False) +class Raise(Element): + """Raise class for [Class] or [Function].""" + + node: ast.Raise | None + + +def create_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: + """Yield [Raise] instances.""" + for ret in mkapi.ast.iter_raises(node): + if type_ := ret.exc: + if isinstance(type_, ast.Call): + type_ = type_.func + name = ast.unparse(type_) + yield Raise(name, ret, Type(type_), Text(None)) + + +@dataclass(repr=False) +class Return(Element): + """Return class for [Class] or [Function].""" + + node: ast.expr | None + + +def create_returns(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Return]: + """Return a [Return] instance.""" + if node.returns: + yield Return("", node.returns, Type(node.returns), Text(None)) + + +@dataclass(repr=False) +class Import: + """Import class for [Module].""" + + node: ast.Import | ast.ImportFrom + name: str + fullname: str + from_: str | None + level: int + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name})" + + +def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: + """Yield [Import] instances.""" + for alias in node.names: + if isinstance(node, ast.Import): + if alias.asname: + yield Import(node, alias.asname, alias.name, None, 0) + else: + for fullname in iter_parent_modulenames(alias.name): + yield Import(node, fullname, fullname, None, 0) + else: + name = alias.asname or alias.name + from_ = f"{node.module}" + fullname = f"{from_}.{alias.name}" + yield Import(node, name, fullname, from_, node.level) + + +@dataclass(repr=False) +class Attribute(Element): + """Atrribute class for [Module] or [Class].""" + + node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None + default: ast.expr | None + + +def create_attributes(node: ast.ClassDef | ast.Module) -> Iterator[Attribute]: + """Yield [Attribute] instances.""" + for child in mkapi.ast.iter_child_nodes(node): + if isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): + attr = create_attribute(child) + if attr.name: + yield attr + elif isinstance(child, ast.FunctionDef) and is_property(child): + yield create_attribute_from_property(child) + + +def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: + """Return an [Attribute] instance.""" + name = mkapi.ast.get_assign_name(node) or "" + type_ = mkapi.ast.get_assign_type(node) + type_, text = _attribute_type_text(type_, node.__doc__) + default = None if isinstance(node, ast.TypeAlias) else node.value + return Attribute(name, node, type_, text, default) + + +def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: + """Return an [Attribute] instance from a property.""" + text = ast.get_docstring(node) + type_, text = _attribute_type_text(node.returns, text) + return Attribute(node.name, node, type_, text, None) + + +def _attribute_type_text(type_: ast.expr | None, text: str | None) -> tuple[Type, Text]: + if not text: + return Type(type_), Text(None) + type_doc, text = docstrings.split_without_name(text, "google") + if not type_ and type_doc: + # ex. 'list(str)' -> 'list[str]' for ast.expr + type_doc = type_doc.replace("(", "[").replace(")", "]") + type_ = mkapi.ast.create_expr(type_doc) + return Type(type_), Text(text) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index aba2db96..89554033 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -12,9 +12,11 @@ import mkapi.ast import mkapi.dataclasses from mkapi import docstrings +from mkapi.elements import Text, Type from mkapi.utils import ( del_by_name, get_by_name, + get_module_path, iter_parent_modulenames, unique_names, ) @@ -24,23 +26,7 @@ from inspect import _ParameterKind from typing import Self - from mkapi.docstrings import Item, Section - - -@dataclass -class Type: - """Type class.""" - - expr: ast.expr - markdown: str = field(default="", init=False) - - -@dataclass -class Text: - """Text class.""" - - str: str # noqa: A003 - markdown: str = field(default="", init=False) + from mkapi.docstrings import Docstring, Item, Section @dataclass @@ -49,11 +35,9 @@ class Object: node: ast.AST name: str - module: Module | None = field(init=False) - text: Text | None = field(init=False) - type: Type | None = field(init=False) # noqa: A003 - _text: InitVar[str | None] - _type: InitVar[ast.expr | None] + module: Module + text: Text + type: Type # noqa: A003 def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: self.module = Module.current @@ -85,83 +69,6 @@ def iter_texts(self) -> Iterator[Text]: yield self.text -@dataclass(repr=False) -class Parameter(Object): - """Parameter class for [Class] and [Function].""" - - node: ast.arg | None - default: ast.expr | None - kind: _ParameterKind | None - - -def create_parameters( - node: ast.FunctionDef | ast.AsyncFunctionDef, -) -> Iterator[Parameter]: - """Yield parameters from the function node.""" - for arg, kind, default in mkapi.ast.iter_parameters(node): - yield Parameter(arg, arg.arg, None, arg.annotation, default, kind) - - -@dataclass(repr=False) -class Raise(Object): - """Raise class for [Class] and [Function].""" - - node: ast.Raise | None - - def __repr__(self) -> str: - exc = ast.unparse(self.type.expr) if self.type else "" - return f"{self.__class__.__name__}({exc})" - - -def create_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: - """Yield [Raise] instances.""" - for child in ast.walk(node): - if isinstance(child, ast.Raise) and (name := child.exc): - if isinstance(name, ast.Call): - name = name.func - yield Raise(child, "", None, name) - - -@dataclass(repr=False) -class Return(Object): - """Return class for [Class] and [Function].""" - - node: ast.expr | None - - -def create_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Return: - """Return a [Return] instance.""" - return Return(node.returns, "", None, node.returns) - - -@dataclass(repr=False) -class Import: - """Import class for [Module].""" - - node: ast.Import | ast.ImportFrom - name: str - fullname: str - from_: str | None - level: int - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name})" - - -def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: - """Yield [Import] instances.""" - for alias in node.names: - name = alias.asname or alias.name - if isinstance(node, ast.Import): - for parent in iter_parent_modulenames(name): - # TODO: ex. import matplotlib.pyplot as plt - yield Import(node, parent, parent, None, 0) - else: - from_ = f"{node.module}" - fullname = f"{from_}.{alias.name}" - yield Import(node, name, fullname, from_, node.level) - - objects: dict[str, Attribute | Class | Function | Module | None] = {} @@ -176,58 +83,21 @@ def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: super().__post_init__(_text, _type) qualname = Class.classnames[-1] self.qualname = f"{qualname}.{self.name}" if qualname else self.name - m_name = self.module.name if self.module else "__mkapi__" - self.fullname = f"{m_name}.{self.qualname}" if m_name else self.qualname + if self.module: + self.fullname = f"{self.module.name}.{self.qualname}" + else: + self.fullname = f"{self.qualname}" objects[self.fullname] = self # type:ignore - def iter_members(self) -> Iterator[Member]: - """Yield [Member] instances.""" - yield from [] + # def iter_members(self) -> Iterator[Member]: + # """Yield [Member] instances.""" + # yield from [] @property def id(self) -> str: # noqa: A003, D102 return self.fullname -@dataclass(repr=False) -class Attribute(Member): - """Atrribute class for [Module] and [Class].""" - - node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None - default: ast.expr | None - - -def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attribute: - """Return an [Attribute] instance.""" - name = mkapi.ast.get_assign_name(node) or "" - type_ = mkapi.ast.get_assign_type(node) - text, type_ = _attribute_text_type(node.__doc__, type_) - default = None if isinstance(node, ast.TypeAlias) else node.value - return Attribute(node, name, text, type_, default) - - -def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: - """Return an [Attribute] instance from a property.""" - doc = ast.get_docstring(node) - type_ = node.returns - text, type_ = _attribute_text_type(doc, type_) - return Attribute(node, node.name, text, type_, None) - - -def _attribute_text_type( - doc: str | None, - type_: ast.expr | None, -) -> tuple[str | None, ast.expr | None]: - if not doc: - return doc, type_ - type_doc, text = docstrings.split_without_name(doc, "google") - if not type_ and type_doc: - # ex. list(str) -> list[str] - type_doc = type_doc.replace("(", "[").replace(")", "]") - type_ = mkapi.ast.create_expr(type_doc) - return text, type_ - - @dataclass(repr=False) class Callable(Member): """Callable class for [Class] and [Function].""" @@ -457,20 +327,6 @@ def get_link(name: str, fullname: str) -> str: return f"[{name}][__mkapi__.{fullname}]" -def get_module_path(name: str) -> Path | None: - """Return the source path of the module name.""" - try: - spec = importlib.util.find_spec(name) - except ModuleNotFoundError: - return None - if not spec or not hasattr(spec, "origin") or not spec.origin: - return None - path = Path(spec.origin) - if not path.exists(): # for builtin, frozen - return None - return path - - modules: dict[str, Module | None] = {} @@ -505,9 +361,9 @@ def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module Module.current = module for member in create_members(node): module.add_member(member) + _postprocess(module) Module.current = None module.set_markdown() - _postprocess(module) return module diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index f2258223..ba9a4232 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -10,6 +10,20 @@ from collections.abc import Callable, Iterable, Iterator +def get_module_path(name: str) -> Path | None: + """Return the source path of the module name.""" + try: + spec = find_spec(name) + except ModuleNotFoundError: + return None + if not spec or not hasattr(spec, "origin") or not spec.origin: + return None + path = Path(spec.origin) + if not path.exists(): # for builtin, frozen + return None + return path + + def _is_module(path: Path, exclude_patterns: Iterable[str] = ()) -> bool: path_str = path.as_posix() for pattern in exclude_patterns: diff --git a/src_old/mkapi/core/__init__.py b/src_old/mkapi/core/__init__.py deleted file mode 100644 index 5ffcfb2c..00000000 --- a/src_old/mkapi/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package level documentation is written in `__init__.py`.""" diff --git a/src_old/mkapi/core/code.py b/src_old/mkapi/core/code.py deleted file mode 100644 index 26c1729f..00000000 --- a/src_old/mkapi/core/code.py +++ /dev/null @@ -1,89 +0,0 @@ -"""This module provides Code class for source code.""" -import io -import re -from dataclasses import dataclass, field -from typing import List - -import markdown - -from mkapi.core.module import Module, load_module - - -@dataclass -class Code: - """Code class represents source code of an object.""" - - module: Module #: Module instance. - markdown: str = field(default="", init=False) #: Markdown source. - html: str = field(default="", init=False) #: Converted HTML. - level: int = field(default=1, init=False) #: Heading level. - - def __post_init__(self): - sourcefile = self.module.sourcefile - with open(sourcefile, encoding="utf-8-sig", errors="strict") as f: - source = f.read() - if not source: - return - - nodes = [] - linenos = [] - for node in self.module.node.walk(): - if node.sourcefile == sourcefile: - if node.lineno > 0 and node.lineno not in linenos: - nodes.append(node) - linenos.append(node.lineno) - module_id = self.module.object.id - i = 0 - lines = [] - for k, line in enumerate(source.split("\n")): - if i < len(linenos) and k == linenos[i]: - object_id = nodes[i].object.id - lines.append(f" # __mkapi__:{module_id}:{object_id}") - i += 1 - lines.append(" " + line) - source = "\n".join(lines) - self.markdown = f" :::python\n{source}\n" - html = markdown.markdown(self.markdown, extensions=["codehilite"]) - self.html = replace(html) - - def __repr__(self): - class_name = self.__class__.__name__ - return f"{class_name}({self.module.object.id!r})" - - def get_markdown(self, level: int = 1) -> str: - """Returns a Markdown source for docstring of this object.""" - self.level = level - return f"# {self.module.object.id}" - - def set_html(self, html: str): - pass - - def get_html(self, filters: list[str] = None) -> str: - """Renders and returns HTML.""" - from mkapi.core.renderer import renderer - - return renderer.render_code(self, filters) # type:ignore - - -COMMENT_PATTERN = re.compile(r'\n# __mkapi__:(.*?):(.*?)') - - -def replace(html): - def func(match): - module, object = match.groups() - link = f'' - link += f'DOCS' - return link - - return COMMENT_PATTERN.sub(func, html) - - -def get_code(name: str) -> Code: - """Returns a Code instace by module name. - - Args: - name: Module name. - """ - module = load_module(name) - return Code(module) diff --git a/src_old/mkapi/core/inherit.py b/src_old/mkapi/core/inherit.py deleted file mode 100644 index a78ae45c..00000000 --- a/src_old/mkapi/core/inherit.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Functionality of docstring inheritance.""" -from collections.abc import Iterator - -from mkapi.core.base import Section -from mkapi.core.node import Node, get_node -from mkapi.core.object import get_mro - - -def get_section(node: Node, name: str, mode: str) -> Section: - """Return a tuple of (docstring section, signature section). - - Args: - node: Node instance. - name: Section name: `Parameters` or `Attributes`. - mode: Mode name: `Docstring` or `Signature`. - - Examples: - >>> node = get_node("mkapi.core.base.Type") - >>> section = get_section(node, "Parameters", "Docstring") - >>> "name" in section - True - >>> section["name"].to_tuple() - ('name', 'str, optional', '') - """ - if mode == "Docstring": - if name in node.docstring: - return node.docstring[name] - return Section(name) - if hasattr(node.object.signature, name.lower()): - return node.object.signature[name] - return Section(name) - - -def is_complete(node: Node, name: str = "both") -> bool: - """Return True if docstring is complete. - - Args: - node: Node instance. - name: Section name: 'Parameters' or 'Attributes', or 'both'. - If name is 'both', both sections are checked. - - Examples: - >>> from mkapi.core.object import get_object - >>> node = Node(get_object("mkapi.core.base.Base")) - >>> is_complete(node, "Parameters") - True - >>> node = Node(get_object("mkapi.core.base.Type")) - >>> is_complete(node) - False - """ - if name == "both": - return all(is_complete(node, name) for name in ["Parameters", "Attributes"]) - - doc_section = get_section(node, name, "Docstring") - sig_section = get_section(node, name, "Signature") - for item in sig_section.items: - if item.name not in doc_section: - return False - if not doc_section: - return True - return all(item.description.name for item in doc_section.items) - - -def inherit_base(node: Node, base: Node, name: str = "both") -> None: - """Inherit Parameters or Attributes section from base class. - - Args: - node: Node instance. - base: Node instance of a super class. - name: Section name: 'Parameters' or 'Attributes', or 'both'. - If name is 'both', both sections are inherited. - - Examples: - >>> from mkapi.core.object import get_object - >>> base = Node(get_object("mkapi.core.base.Base")) - >>> node = Node(get_object("mkapi.core.base.Type")) - >>> node.docstring["Parameters"]["name"].to_tuple() - ('name', 'str, optional', '') - >>> inherit_base(node, base) - >>> node.docstring["Parameters"]["name"].to_tuple() - ('name', 'str, optional', 'Name of self.') - """ - if name == "both": - for name in ["Parameters", "Attributes"]: - inherit_base(node, base, name) - return - - base_section = get_section(base, name, "Docstring") - node_section = get_section(node, name, "Docstring") - section = base_section.merge(node_section, force=True) - if name == "Parameters": - sig_section = get_section(node, name, "Signature") - items = [item for item in section.items if item.name in sig_section] - section.items = items - if section: - node.docstring.set_section(section, replace=True) - - -def get_bases(node: Node) -> Iterator[tuple[Node, Iterator[Node]]]: - """Yields a tuple of (Node instance, iterator of Node). - - Args: - node: Node instance. - - Examples: - >>> from mkapi.core.object import get_object - >>> node = Node(get_object("mkapi.core.base.Type")) - >>> it = get_bases(node) - >>> n, gen = next(it) - >>> n is node - True - >>> [x.object.name for x in gen] - ['Inline', 'Base'] - """ - bases = get_mro(node.obj)[1:] - yield node, (get_node(base) for base in bases) - for member in node.members: - name = member.object.name - - def gen(name: str = name) -> Iterator[Node]: - for base in bases: - if hasattr(base, name): - obj = getattr(base, name) - if hasattr(obj, "__module__"): - yield get_node(getattr(base, name)) - - yield member, gen() - - -def inherit(node: Node) -> None: - """Inherit Parameters and Attributes from superclasses. - - Args: - node: Node instance. - """ - if node.object.kind not in ["class", "dataclass"]: - return - for node_, bases in get_bases(node): - if is_complete(node_): - continue - for base in bases: - inherit_base(node_, base) - if is_complete(node_): - break diff --git a/src_old/mkapi/core/module.py b/src_old/mkapi/core/module.py deleted file mode 100644 index fcb4e11f..00000000 --- a/src_old/mkapi/core/module.py +++ /dev/null @@ -1,113 +0,0 @@ -"""This modules provides Module class that has tree structure.""" -import inspect -import os -from collections.abc import Iterator -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - -from mkapi.core.node import Node, get_node -from mkapi.core.node import get_members as get_node_members -from mkapi.core.object import get_object -from mkapi.core.structure import Tree - - -@dataclass(repr=False) -class Module(Tree): - """Module class represents a module. - - Attributes: - parent: Parent Module instance. - members: Member Module instances. - node: Node inspect of self. - """ - - parent: Optional["Module"] = field(default=None, init=False) - members: list["Module"] = field(init=False) - node: Node = field(init=False) - - def __post_init__(self) -> None: - super().__post_init__() - self.node = get_node(self.obj) - - def __iter__(self) -> Iterator["Module"]: - if self.docstring: # noqa: SIM114 - yield self - elif self.object.kind in ["package", "module"] and any( - m.docstring for m in self.members - ): - yield self - if self.object.kind == "package": - for member in self.members: - yield from member - - def get_kind(self) -> str: # noqa: D102 - if not self.sourcefile or self.sourcefile.endswith("__init__.py"): - return "package" - return "module" - - def get_members(self) -> list: # noqa: D102 - if self.object.kind == "module": - return get_node_members(self.obj) - return get_members(self.obj) - - def get_markdown(self, filters: list[str]) -> str: - """Return a Markdown source for docstring of this object. - - Args: - filters: A list of filters. Avaiable filters: `upper`, `inherit`, - `strict`. - """ - from mkapi.core.renderer import renderer - - return renderer.render_module(self, filters) - - -def get_members(obj: object) -> list[Module]: - """Return members.""" - try: - sourcefile = inspect.getsourcefile(obj) # type: ignore - except TypeError: - return [] - if not sourcefile: - return [] - root = Path(sourcefile).parent - paths = [path for path in os.listdir(root) if not path.startswith("_")] - members = [] - for path in paths: - root_ = root / path - name = "" - if Path.is_dir(root_) and "__init__.py" in os.listdir(root_): - name = path - elif path.endswith(".py"): - name = path[:-3] - if name: - name = f"{obj.__name__}.{name}" - module = load_module(name) - members.append(module) - packages = [] - modules = [] - for member in members: - if member.object.kind == "package": - packages.append(member) - else: - modules.append(member) - return modules + packages - - -modules: dict[str, Module] = {} - - -def load_module(name: str) -> Module: - """Return a Module instace by name or object. - - Args: - name: Object name or object itself. - """ - obj = get_object(name) if isinstance(name, str) else name - name = obj.__name__ - if name in modules: - return modules[name] - module = Module(obj) - modules[name] = module - return module diff --git a/src_old/mkapi/core/object.py b/src_old/mkapi/core/object.py deleted file mode 100644 index 0f59c1c0..00000000 --- a/src_old/mkapi/core/object.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Utility functions relating to object.""" -import abc -import importlib -import inspect -from typing import Any - - -def get_object(name: str) -> Any: # noqa: ANN401 - """Reutrns an object specified by `name`. - - Args: - name: Object name. - - Examples: - >>> import inspect - >>> obj = get_object('mkapi.core') - >>> inspect.ismodule(obj) - True - >>> obj = get_object('mkapi.core.base') - >>> inspect.ismodule(obj) - True - >>> obj = get_object('mkapi.core.node.Node') - >>> inspect.isclass(obj) - True - >>> obj = get_object('mkapi.core.node.Node.get_markdown') - >>> inspect.isfunction(obj) - True - """ - names = name.split(".") - for k in range(len(names), 0, -1): - modulename = ".".join(names[:k]) - try: - obj = importlib.import_module(modulename) - except ModuleNotFoundError: - continue - for attr in names[k:]: - obj = getattr(obj, attr) - return obj - msg = f"Could not find object: {name}" - raise ValueError(msg) - - -def get_fullname(obj: object, name: str) -> str: - """Reutrns an object full name specified by `name`. - - Args: - obj: Object that has a module. - name: Object name in the module. - - Examples: - >>> obj = get_object("mkapi.core.base.Item") - >>> get_fullname(obj, "Section") - 'mkapi.core.base.Section' - >>> get_fullname(obj, "add_fence") - 'mkapi.core.preprocess.add_fence' - >>> get_fullname(obj, 'abc') - '' - """ - obj = inspect.getmodule(obj) - for name_ in name.split("."): - if not hasattr(obj, name_): - return "" - obj = getattr(obj, name_) - - if isinstance(obj, property): - return "" - - return ".".join(split_prefix_and_name(obj)) - - -def split_prefix_and_name(obj: object) -> tuple[str, str]: - """Splits an object full name into prefix and name. - - Args: - obj: Object that has a module. - - Examples: - >>> import inspect - >>> obj = get_object('mkapi.core') - >>> split_prefix_and_name(obj) - ('mkapi', 'core') - >>> obj = get_object('mkapi.core.base') - >>> split_prefix_and_name(obj) - ('mkapi.core', 'base') - >>> obj = get_object('mkapi.core.node.Node') - >>> split_prefix_and_name(obj) - ('mkapi.core.node', 'Node') - >>> obj = get_object('mkapi.core.node.Node.get_markdown') - >>> split_prefix_and_name(obj) - ('mkapi.core.node.Node', 'get_markdown') - """ - if inspect.ismodule(obj): - prefix, _, name = obj.__name__.rpartition(".") - else: - module = obj.__module__ - qualname = obj.__qualname__ - if "." not in qualname: - prefix, name = module, qualname - else: - prefix, _, name = qualname.rpartition(".") - prefix = f"{module}.{prefix}" - if prefix == "__main__": - prefix = "" - return prefix, name - - -def get_qualname(obj: object) -> str: - """Return `qualname`.""" - if hasattr(obj, "__qualname__"): - return obj.__qualname__ - return "" - - -def get_sourcefile_and_lineno(obj: type) -> tuple[str, int]: - """Return source file and line number.""" - try: - sourcefile = inspect.getsourcefile(obj) or "" - except TypeError: - sourcefile = "" - try: - _, lineno = inspect.getsourcelines(obj) - except (TypeError, OSError): - lineno = -1 - return sourcefile, lineno - - -# Issue#19 (metaclass). TypeError: descriptor 'mro' of 'type' object needs an argument. -def get_mro(obj: Any) -> list[type]: # noqa: D103, ANN401 - try: - objs = obj.mro()[:-1] # drop ['object'] - except TypeError: - objs = obj.mro(obj)[:-2] # drop ['type', 'object'] - if objs[-1] == abc.ABC: - objs = objs[:-1] - return objs - - -def get_sourcefiles(obj: object) -> list[str]: - """Returns a list of source file. - - If `obj` is a class, source files of its superclasses are also included. - - Args: - obj: Object name. - """ - objs = get_mro(obj) if inspect.isclass(obj) and hasattr(obj, "mro") else [obj] - sourfiles = [] - for obj in objs: - try: - sourcefile = inspect.getsourcefile(obj) or "" # type: ignore - except TypeError: - pass - else: - if sourcefile: - sourfiles.append(sourcefile) - return sourfiles - - -def from_object(obj: object) -> bool: - """Returns True, if the docstring of `obj` is the same as that of `object`. - - Args: - name: Object name. - obj: Object. - - Examples: - >>> class A: pass - >>> from_object(A.__call__) - True - >>> from_object(A.__eq__) - True - >>> from_object(A.__getattribute__) - True - """ - if not hasattr(obj, "__name__"): - return False - name = obj.__name__ - if not hasattr(object, name): - return False - return inspect.getdoc(obj) == getattr(object, name).__doc__ - - -def get_origin(obj): # noqa: ANN001, ANN201 - """Returns an original object. - - Examples: - >>> class A: - ... @property - ... def x(self): - ... pass - >>> hasattr(A.x, __name__) - False - >>> get_origin(A.x).__name__ - 'x' - """ - if isinstance(obj, property): - return get_origin(obj.fget) - if not callable(obj): - return obj - try: - wrapped = obj.__wrapped__ - except AttributeError: - pass - else: - return get_origin(wrapped) - try: - wrapped = obj.__pytest_wrapped__ - except AttributeError: - pass - else: - return get_origin(wrapped.obj) - - return obj diff --git a/src_old/mkapi/core/postprocess.py b/src_old/mkapi/core/postprocess.py deleted file mode 100644 index ed2e3fa7..00000000 --- a/src_old/mkapi/core/postprocess.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Postprocess.""" -from mkapi.core.base import Inline, Item, Section, Type -from mkapi.core.node import Node -from mkapi.core.renderer import renderer -from mkapi.core.structure import Object - - -def sourcelink(obj: Object) -> str: # noqa: D103 - link = f'' if "property" in obj.kind else "" - link += f'</>' - return link - - -def source_link_from_section_item(item: Item, obj: Object) -> None: # noqa: D103 - link = sourcelink(obj) - - def callback(inline: Inline, link: str = link) -> str: - return inline.html + link - - item.description.callback = callback - - -def transform_property(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 - section, members = None, [] - for member in node.members: - obj = member.object - if "property" in obj.kind: - if section is None: - section = node.docstring["Attributes"] - description = member.docstring.sections[0].markdown - item = Item(obj.name, obj.type, Inline(description), kind=obj.kind) - if filters and "sourcelink" in filters: - source_link_from_section_item(item, obj) - section.items.append(item) - else: - members.append(member) - node.members = members - - -def get_type(node: Node) -> Type: # noqa: D103 - if type_ := node.object.type: - return Type(type_.name) - for name in ["Returns", "Yields"]: - if name in node.docstring and (section := node.docstring[name]).type: - return Type(section.type.name) - return Type() - - -def get_description(member: Node) -> str: # noqa: D103 - if member.docstring and "" in member.docstring: - description = member.docstring[""].markdown - return description.split("\n\n")[0] - return "" - - -def get_url(node: Node, obj: Object, filters: list[str]) -> str: # noqa: D103 - if "link" in filters or "all" in filters: - return "#" + obj.id - if filters and "apilink" in filters: - return "../" + node.object.id + "#" + obj.id - return "" - - -def get_arguments(obj: Object) -> list[str] | None: # noqa: D103 - if obj.kind not in ["class", "dataclass"]: - return [item.name for item in obj.signature.parameters.items] - return None - - -def transform_members(node: Node, mode: str, filters: list[str] | None = None) -> None: # noqa: D103 - def is_member(kind: str) -> bool: - if mode in ["method", "function"]: - return mode in kind or kind == "generator" - return mode in kind and "method" not in kind - - members = [member for member in node.members if is_member(member.object.kind)] - if not members: - return - - name = mode[0].upper() + mode[1:] + ("es" if mode == "class" else "s") - section = Section(name) - for member in members: - obj = member.object - description = get_description(member) - item = Item(obj.name, get_type(member), Inline(description), obj.kind) - url = get_url(node, obj, filters) if filters else "" - signature = {"arguments": get_arguments(obj)} - item.html = renderer.render_object_member(obj.name, url, signature) - item.markdown = "" - if filters and "sourcelink" in filters: - source_link_from_section_item(item, obj) - section.items.append(item) - node.docstring.set_section(section) - - -def transform_class(node: Node, filters: list[str] | None = None) -> None: # noqa:D103 - if filters is None: - filters = [] - transform_property(node, filters) - transform_members(node, "class", ["link", *filters]) - transform_members(node, "method", ["link", *filters]) - - -def transform_module(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 - transform_members(node, "class", filters) - transform_members(node, "function", filters) - if not filters or "all" not in filters: - node.members = [] - - -def sort_sections(node: Node) -> None: # noqa: D103 - for section in node.docstring.sections: - if section.name not in ["Classes", "Parameters"]: - section.items = sorted(section.items, key=lambda x: x.name) - - -def transform(node: Node, filters: list[str] | None = None) -> None: # noqa: D103 - if node.object.kind.replace("abstract ", "") in ["class", "dataclass"]: - transform_class(node, filters) - elif node.object.kind in ["module", "package"]: - transform_module(node, filters) - for x in node.walk(): - sort_sections(x) - for member in node.members: - transform(member, filters) diff --git a/src_old/mkapi/core/structure.py b/src_old/mkapi/core/structure.py deleted file mode 100644 index 33fdc1b9..00000000 --- a/src_old/mkapi/core/structure.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Base class of [Node](mkapi.nodes.Node) and [Module](mkapi.modules.Module).""" -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Self - -from mkapi.docstrings import Base, Docstring, Type, parse_docstring - -if TYPE_CHECKING: - from collections.abc import Iterator - - -@dataclass -class Object(Base): - """Object class represents an object. - - Args: - name: Object name. - prefix: Object prefix. - qualname: Qualified name. - kind: Object kind such as 'class', 'function', *etc.* - signature: Signature if object is module or callable. - - Attributes: - id: ID attribute of HTML. - type: Type for missing Returns and Yields sections. - """ - - prefix: str = "" - qualname: str = "" - kind: str = "" - signature: Signature = field(default_factory=Signature) - module: str = field(init=False) - markdown: str = field(init=False) - id: str = field(init=False) # noqa: A003 - type: Type = field(default_factory=Type, init=False) # noqa: A003 - - def __post_init__(self) -> None: - from mkapi.core import link - - self.id = self.name - if self.prefix: - self.id = f"{self.prefix}.{self.name}" - if not self.qualname: - self.module = self.id - else: - self.module = self.id[: -len(self.qualname) - 1] - if not self.markdown: - name = link.link(self.name, self.id) - if self.prefix: - prefix = link.link(self.prefix, self.prefix) - self.markdown = f"{prefix}.{name}" - else: - self.markdown = name - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.id!r})" - - def __iter__(self) -> Iterator[Base]: - yield from self.type - yield self - - -@dataclass -class Tree: - """Base of [Node](mkapi.core.node.Node) and [Module](mkapi.core.module.Module). - - Args: - obj: Object. - - Attributes: - sourcefile: Source file path. - lineno: Line number. - object: Object instance. - docstring: Docstring instance. - parent: Parent instance. - members: Member instances. - """ - - obj: Any = field() - sourcefile: str = field(init=False) - lineno: int = field(init=False) - object: Object = field(init=False) # noqa: A003 - docstring: Docstring = field(init=False) - parent: Any = field(default=None, init=False) - members: list[Self] = field(init=False) - - def __post_init__(self) -> None: - obj = get_origin(self.obj) - self.sourcefile, self.lineno = get_sourcefile_and_lineno(obj) - prefix, name = split_prefix_and_name(obj) - qualname = get_qualname(obj) - kind = self.get_kind() - signature = get_signature(obj) - self.object = Object( - prefix=prefix, - name=name, - qualname=qualname, - kind=kind, - signature=signature, - ) - self.docstring = get_docstring(obj) - self.obj = obj - self.members = self.get_members() - for member in self.members: - member.parent = self - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - id_ = self.object.id - sections = len(self.docstring.sections) - numbers = len(self.members) - return f"{class_name}({id_!r}, num_sections={sections}, num_members={numbers})" - - def __getitem__(self, index: int | str | list[str]) -> Self: - """Return a member {class} instance. - - If `index` is str, a member Tree instance whose name is equal to `index` - is returned. - - Raises: - IndexError: If no member found. - """ - if isinstance(index, list): - node = self - for name in index: - node = node[name] - return node - if isinstance(index, int): - return self.members[index] - if isinstance(index, str) and "." in index: - names = index.split(".") - return self[names] - for member in self.members: - if member.object.name == index: - return member - raise IndexError - - def __len__(self) -> int: - return len(self.members) - - def __contains__(self, name: str) -> bool: - return any(member.object.name == name for member in self.members) - - def get_kind(self) -> str: - """Returns kind of self.""" - raise NotImplementedError - - def get_members(self) -> list[Self]: - """Returns a list of members.""" - raise NotImplementedError - - def get_markdown(self) -> str: - """Returns a Markdown source for docstring of self.""" - raise NotImplementedError - - def walk(self) -> Iterator[Self]: - """Yields all members.""" - yield self - for member in self.members: - yield from member.walk() diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index efa8ae94..13dccfe9 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -5,7 +5,7 @@ import pytest from mkapi.ast import iter_child_nodes -from mkapi.objects import get_module_path +from mkapi.utils import get_module_path def load_module(name): diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 2392088f..871a0ad0 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -97,21 +97,21 @@ def test_iter_items_class(google, get, get_node): doc = get(google, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] - x = list(iter_items(section, "google")) + x = list(iter_items(section, "google", "A")) assert x[0].name == "attr1" - assert x[0].type == "str" - assert x[0].text == "Description of `attr1`." + assert x[0].type.expr.value == "str" # type: ignore + assert x[0].text.str == "Description of `attr1`." assert x[1].name == "attr2" - assert x[1].type == ":obj:`int`, optional" - assert x[1].text == "Description of `attr2`." + assert x[1].type.expr.value == ":obj:`int`, optional" # type: ignore + assert x[1].text.str == "Description of `attr2`." doc = get(get_node(google, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] - x = list(iter_items(section, "google")) + x = list(iter_items(section, "google", "A")) assert x[0].name == "param1" - assert x[0].type == "str" - assert x[0].text == "Description of `param1`." - assert x[1].text == "Description of `param2`. Multiple\nlines are supported." + assert x[0].type.expr.value == "str" # type: ignore + assert x[0].text.str == "Description of `param1`." + assert x[1].text.str == "Description of `param2`. Multiple\nlines are supported." def test_split_without_name(google, get): @@ -124,11 +124,6 @@ def test_split_without_name(google, get): assert x[1].endswith(" }") -def test_repr(google): - r = repr(parse(ast.get_docstring(google), "google")) # type: ignore - assert r == "Docstring(num_sections=6)" - - def test_iter_items_raises(google, get): doc = get(google, "module_level_function") assert isinstance(doc, str) @@ -136,5 +131,16 @@ def test_iter_items_raises(google, get): assert name == "Raises" items = list(iter_items(section, "google", name)) assert len(items) == 2 - assert items[0].type == items[0].name == "AttributeError" - assert items[1].type == items[1].name == "ValueError" + assert items[0].type.expr.value == items[0].name == "AttributeError" # type: ignore + assert items[1].type.expr.value == items[1].name == "ValueError" # type: ignore + + +def test_parse(google): + doc = parse(ast.get_docstring(google), "google") # type: ignore + assert doc.text.str == "Example Google style docstrings." + assert doc.sections[0].text.str.startswith("This module") # type: ignore + + +def test_repr(google): + r = repr(parse(ast.get_docstring(google), "google")) # type: ignore + assert r == "Docstring(num_sections=6)" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index b6afa9c1..8f0f8006 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -1,17 +1,25 @@ -from mkapi.docstrings import Item, iter_merged_items, merge, parse +import ast + +from mkapi.docstrings import Item, Text, Type, iter_merged_items, merge, parse def test_iter_merged_items(): - a = [Item("a", "", "item a"), Item("b", "int", "item b")] - b = [Item("a", "str", "item A"), Item("c", "list", "item c")] + a = [ + Item("a", Type(None), Text("item a")), + Item("b", Type(ast.Constant("int")), Text("item b")), + ] + b = [ + Item("a", Type(ast.Constant("str")), Text("item A")), + Item("c", Type(ast.Constant("list")), Text("item c")), + ] c = list(iter_merged_items(a, b)) assert c[0].name == "a" - assert c[0].type == "str" - assert c[0].text == "item a" + assert c[0].type.expr.value == "str" # type: ignore + assert c[0].text.str == "item a" assert c[1].name == "b" - assert c[1].type == "int" + assert c[1].type.expr.value == "int" # type: ignore assert c[2].name == "c" - assert c[2].type == "list" + assert c[2].type.expr.value == "list" # type: ignore def test_merge(google, get, get_node): @@ -20,4 +28,4 @@ def test_merge(google, get, get_node): doc = merge(a, b) assert doc assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] - doc.sections[-1].text.endswith("with it.") + doc.sections[-1].text.str.endswith("with it.") # type: ignore diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index b036c39c..45edcb4e 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -93,21 +93,21 @@ def test_iter_items_class(numpy, get, get_node): doc = get(numpy, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] - x = list(iter_items(section, "numpy")) + x = list(iter_items(section, "numpy", "A")) assert x[0].name == "attr1" - assert x[0].type == "str" - assert x[0].text == "Description of `attr1`." + assert x[0].type.expr.value == "str" # type: ignore + assert x[0].text.str == "Description of `attr1`." assert x[1].name == "attr2" - assert x[1].type == ":obj:`int`, optional" - assert x[1].text == "Description of `attr2`." + assert x[1].type.expr.value == ":obj:`int`, optional" # type: ignore + assert x[1].text.str == "Description of `attr2`." doc = get(get_node(numpy, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] - x = list(iter_items(section, "numpy")) + x = list(iter_items(section, "numpy", "A")) assert x[0].name == "param1" - assert x[0].type == "str" - assert x[0].text == "Description of `param1`." - assert x[1].text == "Description of `param2`. Multiple\nlines are supported." + assert x[0].type.expr.value == "str" # type: ignore + assert x[0].text.str == "Description of `param1`." + assert x[1].text.str == "Description of `param2`. Multiple\nlines are supported." def test_get_return(numpy, get): @@ -127,5 +127,5 @@ def test_iter_items_raises(numpy, get): assert name == "Raises" items = list(iter_items(section, "numpy", name)) assert len(items) == 2 - assert items[0].type == items[0].name == "AttributeError" - assert items[1].type == items[1].name == "ValueError" + assert items[0].type.expr.value == items[0].name == "AttributeError" # type: ignore + assert items[1].type.expr.value == items[1].name == "ValueError" # type: ignore diff --git a/tests/elements/__init__.py b/tests/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/elements/conftest.py b/tests/elements/conftest.py new file mode 100644 index 00000000..e4ae57e9 --- /dev/null +++ b/tests/elements/conftest.py @@ -0,0 +1,48 @@ +import ast +import sys +from pathlib import Path + +import pytest + +from mkapi.ast import iter_child_nodes +from mkapi.utils import get_module_path + + +@pytest.fixture(scope="module") +def module(): + path = get_module_path("mkdocs.structure.files") + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +def load_module(name): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + path = get_module_path(name) + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +@pytest.fixture(scope="module") +def google(): + return load_module("examples.styles.example_google") + + +@pytest.fixture(scope="module") +def get(google): + def get(name, *rest, node=google): + for child in iter_child_nodes(node): + if not isinstance(child, ast.FunctionDef | ast.ClassDef): + continue + if child.name == name: + if not rest: + return child + return get(*rest, node=child) + raise NameError + + return get diff --git a/tests/elements/test_elements.py b/tests/elements/test_elements.py new file mode 100644 index 00000000..cd80e5e0 --- /dev/null +++ b/tests/elements/test_elements.py @@ -0,0 +1,146 @@ +import ast +from inspect import Parameter + +from mkapi.ast import iter_child_nodes +from mkapi.elements import ( + Element, + Import, + Text, + Type, + create_attributes, + create_parameters, + create_raises, + create_returns, + iter_imports, +) + + +def test_iter_import_nodes(module: ast.Module): + node = next(iter_child_nodes(module)) + assert isinstance(node, ast.ImportFrom) + assert len(node.names) == 1 + alias = node.names[0] + assert node.module == "__future__" + assert alias.name == "annotations" + assert alias.asname is None + + +def test_iter_import_nodes_alias(): + src = "import matplotlib.pyplot" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(iter_imports(node)) + assert len(x) == 2 + assert x[0].fullname == "matplotlib" + assert x[1].fullname == "matplotlib.pyplot" + src = "import matplotlib.pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(iter_imports(node)) + assert len(x) == 1 + assert x[0].fullname == "matplotlib.pyplot" + assert x[0].name == "plt" + src = "from matplotlib import pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.ImportFrom) + x = list(iter_imports(node)) + assert len(x) == 1 + assert x[0].fullname == "matplotlib.pyplot" + assert x[0].name == "plt" + + +def test_create_parameters(get): + func = get("function_with_pep484_type_annotations") + x = list(create_parameters(func)) + assert x[0].name == "param1" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "int" + assert x[1].name == "param2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "str" + + func = get("module_level_function") + x = list(create_parameters(func)) + assert x[0].name == "param1" + assert x[0].type.expr is None + assert x[0].default is None + assert x[0].kind is Parameter.POSITIONAL_OR_KEYWORD + assert x[1].name == "param2" + assert x[1].type.expr is None + assert isinstance(x[1].default, ast.Constant) + assert x[1].default.value is None + assert x[1].kind is Parameter.POSITIONAL_OR_KEYWORD + assert x[2].name == "args" + assert x[2].type.expr is None + assert x[2].default is None + assert x[2].kind is Parameter.VAR_POSITIONAL + assert x[3].name == "kwargs" + assert x[3].type.expr is None + assert x[3].default is None + assert x[3].kind is Parameter.VAR_KEYWORD + + +def test_create_raises(get): + func = get("module_level_function") + x = next(create_raises(func)) + assert x.name == "ValueError" + assert x.text.str is None + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "ValueError" + + +def test_create_returns(get): + func = get("function_with_pep484_type_annotations") + x = next(create_returns(func)) + assert x.name == "" + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "bool" + + +def test_create_attributes(google, get): + x = list(create_attributes(google)) + assert x[0].name == "module_level_variable1" + assert x[0].type.expr is None + assert x[0].text.str is None + assert isinstance(x[0].default, ast.Constant) + assert x[0].default.value == 12345 + assert x[1].name == "module_level_variable2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "int" + assert x[1].text.str + assert x[1].text.str.startswith("Module level") + assert x[1].text.str.endswith("by a colon.") + assert isinstance(x[1].default, ast.Constant) + assert x[1].default.value == 98765 + cls = get("ExamplePEP526Class") + x = list(create_attributes(cls)) + assert x[0].name == "attr1" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "str" + assert x[1].name == "attr2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "int" + + +def test_create_attributes_from_property(get): + cls = get("ExampleClass") + x = list(create_attributes(cls)) + assert x[0].name == "readonly_property" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "str" + assert x[0].text.str + assert x[0].text.str.startswith("Properties should") + assert x[1].name == "readwrite_property" + assert isinstance(x[1].type.expr, ast.Subscript) + assert x[1].text.str + assert x[1].text.str.startswith("Properties with") + + +def test_repr(): + e = Element("abc", None, Type(None), Text(None)) + assert repr(e) == "Element(abc)" + src = "import matplotlib.pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(iter_imports(node)) + assert repr(x[0]) == "Import(plt)" diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py index 0ef6a1a5..7d873f6c 100644 --- a/tests/objects/test_object.py +++ b/tests/objects/test_object.py @@ -11,6 +11,7 @@ Module, get_module_path, get_object, + iter_imports, load_module, modules, objects, @@ -26,117 +27,107 @@ def module(): return ast.parse(source) -def test_iter_import_nodes(module: ast.Module): - node = next(iter_child_nodes(module)) - assert isinstance(node, ast.ImportFrom) - assert len(node.names) == 1 - alias = node.names[0] - assert node.module == "__future__" - assert alias.name == "annotations" - assert alias.asname is None - - -def test_not_found(): - assert load_module("xxx") is None - assert mkapi.objects.modules["xxx"] is None - assert load_module("markdown") - assert "markdown" in mkapi.objects.modules - - -def test_repr(): - module = load_module("mkapi") - assert repr(module) == "Module(mkapi)" - module = load_module("mkapi.objects") - assert repr(module) == "Module(mkapi.objects)" - obj = get_object("mkapi.objects.Object") - assert repr(obj) == "Class(Object)" - - -def test_load_module_source(): - module = load_module("mkdocs.structure.files") - assert module - assert module.source - assert "class File" in module.source - module = load_module("mkapi.plugins") - assert module - cls = module.get_class("MkAPIConfig") - assert cls - assert cls.module is module - src = cls.get_source() - assert src - assert src.startswith("class MkAPIConfig") - src = module.get_source() - assert src - assert "MkAPIPlugin" in src - - -def test_load_module_from_object(): - module = load_module("mkdocs.structure.files") - assert module - c = module.classes[1] - m = c.module - assert module is m - - -def test_fullname(google: Module): - c = google.get_class("ExampleClass") - assert isinstance(c, Class) - f = c.get_function("example_method") - assert isinstance(f, Function) - assert c.fullname == "examples.styles.example_google.ExampleClass" - name = "examples.styles.example_google.ExampleClass.example_method" - assert f.fullname == name - - -def test_cache(): - modules.clear() - objects.clear() - module = load_module("mkapi.objects") - c = get_object("mkapi.objects.Object") - f = get_object("mkapi.objects.Module.get_class") - assert c - assert c.module is module - assert f - assert f.module is module - c2 = get_object("mkapi.objects.Object") - f2 = get_object("mkapi.objects.Module.get_class") - assert c is c2 - assert f is f2 - - m1 = load_module("mkdocs.structure.files") - m2 = load_module("mkdocs.structure.files") - assert m1 is m2 - modules.clear() - m3 = load_module("mkdocs.structure.files") - m4 = load_module("mkdocs.structure.files") - assert m2 is not m3 - assert m3 is m4 - - -def test_module_kind(): - module = load_module("mkapi") - assert module - assert module.kind == "package" - module = load_module("mkapi.objects") - assert module - assert module.kind == "module" - - -def test_get_fullname_with_attr(): - module = load_module("mkapi.plugins") - assert module - name = module.get_fullname("config_options.Type") - assert name == "mkdocs.config.config_options.Type" - assert not module.get_fullname("config_options.A") - - -def test_iter_bases(): - module = load_module("mkapi.objects") - assert module - cls = module.get_class("Class") - assert cls - bases = cls.iter_bases() - assert next(bases).name == "Object" - assert next(bases).name == "Member" - assert next(bases).name == "Callable" - assert next(bases).name == "Class" +# def test_not_found(): +# assert load_module("xxx") is None +# assert mkapi.objects.modules["xxx"] is None +# assert load_module("markdown") +# assert "markdown" in mkapi.objects.modules + + +# def test_repr(): +# module = load_module("mkapi") +# assert repr(module) == "Module(mkapi)" +# module = load_module("mkapi.objects") +# assert repr(module) == "Module(mkapi.objects)" +# obj = get_object("mkapi.objects.Object") +# assert repr(obj) == "Class(Object)" + + +# def test_load_module_source(): +# module = load_module("mkdocs.structure.files") +# assert module +# assert module.source +# assert "class File" in module.source +# module = load_module("mkapi.plugins") +# assert module +# cls = module.get_class("MkAPIConfig") +# assert cls +# assert cls.module is module +# src = cls.get_source() +# assert src +# assert src.startswith("class MkAPIConfig") +# src = module.get_source() +# assert src +# assert "MkAPIPlugin" in src + + +# def test_load_module_from_object(): +# module = load_module("mkdocs.structure.files") +# assert module +# c = module.classes[1] +# m = c.module +# assert module is m + + +# def test_fullname(google: Module): +# c = google.get_class("ExampleClass") +# assert isinstance(c, Class) +# f = c.get_function("example_method") +# assert isinstance(f, Function) +# assert c.fullname == "examples.styles.example_google.ExampleClass" +# name = "examples.styles.example_google.ExampleClass.example_method" +# assert f.fullname == name + + +# def test_cache(): +# modules.clear() +# objects.clear() +# module = load_module("mkapi.objects") +# c = get_object("mkapi.objects.Object") +# f = get_object("mkapi.objects.Module.get_class") +# assert c +# assert c.module is module +# assert f +# assert f.module is module +# c2 = get_object("mkapi.objects.Object") +# f2 = get_object("mkapi.objects.Module.get_class") +# assert c is c2 +# assert f is f2 + +# m1 = load_module("mkdocs.structure.files") +# m2 = load_module("mkdocs.structure.files") +# assert m1 is m2 +# modules.clear() +# m3 = load_module("mkdocs.structure.files") +# m4 = load_module("mkdocs.structure.files") +# assert m2 is not m3 +# assert m3 is m4 + + +# def test_module_kind(): +# module = load_module("mkapi") +# assert module +# assert module.kind == "package" +# module = load_module("mkapi.objects") +# assert module +# assert module.kind == "module" + + +# def test_get_fullname_with_attr(): +# module = load_module("mkapi.plugins") +# assert module +# name = module.get_fullname("config_options.Type") +# assert name == "mkdocs.config.config_options.Type" +# assert not module.get_fullname("config_options.A") + + +# def test_iter_bases(): +# module = load_module("mkapi.objects") +# assert module +# cls = module.get_class("Class") +# assert cls +# bases = cls.iter_bases() +# assert next(bases).name == "Object" +# assert next(bases).name == "Member" +# assert next(bases).name == "Callable" +# assert next(bases).name == "Class" diff --git a/tests/test_ast.py b/tests/test_ast.py index 0566e27f..06130042 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -1,6 +1,11 @@ import ast -from mkapi.ast import StringTransformer, _iter_identifiers, unparse +from mkapi.ast import ( + StringTransformer, + _iter_identifiers, + iter_raises, + unparse, +) def _ast(src: str) -> ast.expr: @@ -67,3 +72,12 @@ def f(s: str) -> str: assert f("a | b.c | d") == " | | " assert f("list[A]") == "[]" assert f("list['A']") == "[]" + + +def test_iter_raises(): + src = "def f():\n raise ValueError('a')\n raise ValueError\n" + node = ast.parse(src).body[0] + assert isinstance(node, ast.FunctionDef) + raises = list(iter_raises(node)) + assert len(raises) == 1 + assert isinstance(raises[0].exc, ast.Call) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 250aecc6..9490ccdb 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -2,13 +2,25 @@ from mkapi.objects import load_module, objects +def f(): + pass + + +class A: + x: int + + def test_node(): - node = load_module("examples.styles.example_google") - print(objects) - node = get_node("examples.styles.example_google") - for m in node.walk(): - print(m) - print(node.object.text) + print(A.__name__, A.__module__) + A.x = 1 + print(A.x.__name__, A.x.__module__) + assert 0 + # node = load_module("examples.styles.example_google") + # print(objects) + # node = get_node("examples.styles.example_google") + # for m in node.walk(): + # print(m) + # print(node.object.text) # assert 0 diff --git a/tests_old/core/test_core_code.py b/tests_old/core/test_core_code.py deleted file mode 100644 index f8085493..00000000 --- a/tests_old/core/test_core_code.py +++ /dev/null @@ -1,6 +0,0 @@ -from mkapi.core.code import get_code - - -def test_code_repr(): - code = get_code('mkapi.core.base') - assert repr(code) == "Code('mkapi.core.base')" diff --git a/tests_old/core/test_core_inherit.py b/tests_old/core/test_core_inherit.py deleted file mode 100644 index eef201da..00000000 --- a/tests_old/core/test_core_inherit.py +++ /dev/null @@ -1,25 +0,0 @@ -from itertools import product - -import pytest - -from mkapi.core.base import Base, Inline -from mkapi.core.inherit import get_section, is_complete -from mkapi.core.node import Node - - -def test_is_complete(): - assert is_complete(Node(Base)) - assert not is_complete(Node(Inline)) - - -@pytest.mark.parametrize( - ("name", "mode"), - product(["Parameters", "Attributes"], ["Docstring", "Signature"]), -) -def test_get_section(name, mode): - def func(): - pass - - section = get_section(Node(func), name, mode) - assert section.name == name - assert not section diff --git a/tests_old/core/test_core_module.py b/tests_old/core/test_core_module.py deleted file mode 100644 index 2b3a6505..00000000 --- a/tests_old/core/test_core_module.py +++ /dev/null @@ -1,36 +0,0 @@ -from mkapi.core.module import load_module, modules - - -def test_load_module(): - module = load_module("mkapi") - assert module.parent is None - assert module.object.markdown == "[mkapi](!mkapi)" - assert "core" in module - core = module["core"] - assert core.parent is module - assert core.object.markdown == "[mkapi](!mkapi).[core](!mkapi.core)" - assert core.object.kind == "package" - assert "base" in core - base = core["base"] - assert base.parent is core - assert base.object.markdown == "[mkapi.core](!mkapi.core).[base](!mkapi.core.base)" - assert base.object.kind == "module" - assert len(base.node.members) == 6 - - -def test_repr(): - module = load_module("mkapi.core.base") - s = "Module('mkapi.core.base', num_sections=2, num_members=6)" - assert repr(module) == s - - -def test_load_module_from_object(): - from mkapi.core import base - - assert load_module(base).obj is base - - -def test_cache(): - assert "mkapi.core.base" in modules - assert "mkapi.core.code" in modules - assert "mkapi.core.docstring" in modules diff --git a/tests_old/core/test_core_node.py b/tests_old/core/test_core_node.py deleted file mode 100644 index ea93ffed..00000000 --- a/tests_old/core/test_core_node.py +++ /dev/null @@ -1,195 +0,0 @@ -from mkapi.core.module import load_module -from mkapi.core.node import Node, get_kind, get_node, get_node_from_module, is_member -from mkapi.inspect import attribute -from mkapi.inspect.signature import Signature - - -def test_generator(): - node = get_node("examples.styles.google.gen") - assert node.object.kind == "generator" - - -def test_class(): - node = get_node("examples.styles.google.ExampleClass") - assert node.object.prefix == "examples.styles.google" - assert node.object.name == "ExampleClass" - assert node.object.kind == "class" - assert len(node) == 3 - p = node.members[-1] - assert p.object.type.name == "list[int]" - assert p.docstring.sections[0].markdown.startswith("Read-write property") - - -def test_dataclass(): - node = get_node("examples.styles.google.ExampleDataClass") - assert node.object.prefix == "examples.styles.google" - assert node.object.name == "ExampleDataClass" - assert node.object.kind == "dataclass" - - -def test_node_object_type(): - class A: - """AAA""" - - node = Node(A) - assert node.object.type.name == "" - - -def test_is_member_private(): - class A: - def _private(self): - pass - - def func(self): - pass - - class B(A): - def _private(self): - pass - - assert is_member(A._private) == -1 # noqa: SLF001 - assert is_member(A.func) == 0 - assert is_member(B.func) == 0 - - -def test_is_member_source_file_index(): - node = get_node("mkapi.core.node.Node") - assert node["__getitem__"].sourcefile_index == 1 - - node = get_node("mkapi.core.base") - assert node["Inline"].sourcefile_index == 0 - - -def test_get_markdown(): - node = get_node("mkapi.core.base.Base") - markdown = node.get_markdown() - parts = [x.strip() for x in markdown.split("")] - x = "[mkapi.core.base](!mkapi.core.base).[Base](!mkapi.core.base.Base)" - assert parts[0] == x - assert parts[1] == "Base class." - markdown = node.get_markdown(level=2) - parts = [x.strip() for x in markdown.split("")] - x = "[mkapi.core.base](!mkapi.core.base).[Base](!mkapi.core.base.Base)" - assert parts[0] == "## " + x - - def callback(_): - return "123" - - markdown = node.get_markdown(callback=callback) - parts = [x.strip() for x in markdown.split("")] - assert all(x == "123" for x in parts) - - -def test_set_html_and_render(): - node = get_node("mkapi.core.base.Base") - markdown = node.get_markdown() - sep = "" - n = len(markdown.split(sep)) - html = sep.join(str(x) for x in range(n)) - node.set_html(html) - for k, x in enumerate(node): - assert x.html == str(k) - - html = node.get_html() - - assert html.startswith('

' in html - assert '
' in html - assert 'mkapi.core.base.' in html - assert 'Base' in html - assert '(' in html - assert 'name='', ' in html - assert 'markdown=''' in html - assert '
1
' in html - assert '2' in html - assert 'set_html' in html - assert '
  • html' in html - - -def test_package(): - node = get_node("mkapi.core") - assert node.object.kind == "package" - - -def test_repr(): - node = get_node("mkapi.core.base") - assert repr(node) == "Node('mkapi.core.base', num_sections=2, num_members=6)" - - -def test_get_kind(): - class A: - def __getattr__(self, name): - raise KeyError - - assert get_kind(A()) == "" - - -def test_get_node_from_module(): - _ = load_module("mkapi.core") - x = get_node("mkapi.core.base.Base.__iter__") - y = get_node("mkapi.core.base.Base.__iter__") - assert x is not y - x = get_node_from_module("mkapi.core.base.Base.__iter__") - y = get_node_from_module("mkapi.core.base.Base.__iter__") - assert x is y - - -def test_get_markdown_bases(): - node = get_node("examples.cls.inherit.Sub") - markdown = node.get_markdown() - parts = [x.strip() for x in markdown.split("")] - x = "[examples.cls.inherit.Base]" - assert parts[1].startswith(x) - - -def test_set_html_and_render_bases(): - node = get_node("examples.cls.inherit.Sub") - markdown = node.get_markdown() - sep = "" - n = len(markdown.split(sep)) - html = sep.join(str(x) for x in range(n)) - node.set_html(html) - html = node.get_html() - assert "mkapi-section bases" in html - - -def test_decorated_member(): - node = get_node(attribute) - assert node.members[-1].object.kind == "function" - assert get_node(Signature)["arguments"].object.kind == "readonly property" - - -def test_colon_in_docstring(): # Issue #17 - class A: - def func(self): - """this: is not type.""" - - @property - def prop(self): - """this: is type.""" - - def func(self): # noqa: ARG001 - """this: is not type.""" - - node = get_node(A) - assert node["func"].docstring[""].markdown == "this: is not type." - assert node["prop"].docstring[""].markdown == "is type." - assert node["prop"].object.type.name == "this" - assert not node["prop"].docstring.type.name - - node = get_node(func) - assert node.docstring[""].markdown == "this: is not type." - assert not node.object.type - - -def test_short_filter(): - node = get_node("mkapi.core.base.Base") - markdown = node.get_markdown() - sep = "" - n = len(markdown.split(sep)) - html = sep.join(str(x) for x in range(n)) - node.set_html(html) - html = node.get_html(filters=["short"]) - sub = '"mkapi-object-body dataclass top">Base' - assert sub in html - assert "prefix" not in html diff --git a/tests_old/core/test_core_node_abc.py b/tests_old/core/test_core_node_abc.py deleted file mode 100644 index 7bf69c8b..00000000 --- a/tests_old/core/test_core_node_abc.py +++ /dev/null @@ -1,61 +0,0 @@ -from abc import ABC, abstractmethod - -from mkapi.core.node import get_node -from mkapi.core.object import get_sourcefiles - - -class C(ABC): - """Abstract class.""" - - def method(self): # noqa: B027 - """Method.""" - - @classmethod # noqa: B027 - def class_method(cls): - """Classmethod.""" - - @staticmethod # noqa: B027 - def static_method(): - """Staticmethod.""" - - @abstractmethod - def abstract_method(self): - """Abstract method.""" - - @classmethod - @abstractmethod - def abstract_classmethod(cls): - """Abstract classmethod.""" - - @staticmethod - @abstractmethod - def abstract_staticmethod(): - """Abstract staticmethod.""" - - @property # type:ignore - @abstractmethod - def abstract_readonly_property(self): - """Abstract readonly property.""" - - @property # type:ignore - @abstractmethod - def abstract_readwrite_property(self): - """Abstract readwrite property.""" - - @abstract_readwrite_property.setter # type:ignore - @abstractmethod - def abstract_readwrite_property(self, val): - pass - - -def test_load_module_sourcefiles(): - files = get_sourcefiles(C) - assert len(files) == 1 - - -def test_abc(): - node = get_node(C) - assert node.object.kind == "abstract class" - for member in node.members: - obj = member.object - assert obj.kind.replace(" ", "") == obj.name.replace("_", "") diff --git a/tests_old/core/test_core_node_init.py b/tests_old/core/test_core_node_init.py deleted file mode 100644 index 647071b1..00000000 --- a/tests_old/core/test_core_node_init.py +++ /dev/null @@ -1,74 +0,0 @@ -from mkapi.core.node import get_node - - -class A: - """Class docstring.""" - - def __init__(self): - pass - - def func(self): - """Function docstring.""" - - -def test_class_docstring(): - node = get_node(A) - assert node.docstring.sections[0].markdown == "Class docstring." - assert len(node.members) == 1 - - -class B: - def __init__(self): - """Init docstring.""" - - def func(self): - """Function docstring.""" - - -def test_init_docstring(): - node = get_node(B) - assert node.docstring.sections[0].markdown == "Init docstring." - assert len(node.members) == 1 - - -class C: - """Class docstring.""" - - def __init__(self): - """Init docstring.""" - - def func(self): - """Function docstring.""" - - -def test_class_and_init_docstring(): - node = get_node(C) - assert node.docstring.sections[0].markdown == "Class docstring." - assert len(node.members) == 1 - - -class D: - def __init__(self): - pass - - def func(self): - """Function docstring.""" - - -def test_without_docstring(): - node = get_node(D) - assert not node.docstring - assert len(node.members) == 1 - - -class E: - """Class docstring.""" - - def func(self): - """Function docstring.""" - - -def test_without_init(): - node = get_node(E) - assert node.docstring.sections[0].markdown == "Class docstring." - assert len(node.members) == 1 diff --git a/tests_old/core/test_core_object.py b/tests_old/core/test_core_object.py deleted file mode 100644 index 44812ac0..00000000 --- a/tests_old/core/test_core_object.py +++ /dev/null @@ -1,34 +0,0 @@ -from mkapi.core.node import Node -from mkapi.core.object import ( - get_object, - get_origin, - get_qualname, - get_sourcefile_and_lineno, - split_prefix_and_name, -) - - -def test_get_object(): - obj = get_object("mkapi.core.node.Node") - assert obj is Node - - -def test_get_origin(): - obj = get_object("mkapi.core.node.Node") - org = get_origin(obj) - assert org is Node - - -def test_load_module_sourcefile_and_lineno(): - sourcefile, _ = get_sourcefile_and_lineno(Node) - assert sourcefile.endswith("node.py") - - -def test_split_prefix_and_name(): - prefix, name = split_prefix_and_name(Node) - assert prefix == "mkapi.core.node" - assert name == "Node" - - -def test_qualname(): - assert get_qualname(Node) == "Node" diff --git a/tests_old/core/test_core_postprocess.py b/tests_old/core/test_core_postprocess.py deleted file mode 100644 index 6d12e7c9..00000000 --- a/tests_old/core/test_core_postprocess.py +++ /dev/null @@ -1,101 +0,0 @@ -from collections.abc import Iterator - -import pytest - -from mkapi.core import postprocess as P -from mkapi.core.node import Node - - -class A: - """AAA""" - - @property - def p(self): - """ppp""" - - def f(self) -> str: # type: ignore - """fff""" - - def g(self) -> int: # type: ignore - """ggg - - aaa - - Returns: - value. - """ - - def a(self) -> tuple[int, str]: # type: ignore - """aaa""" - - def b(self) -> Iterator[str]: - """bbb""" - yield "abc" - - class B: - """BBB""" - - -@pytest.fixture() -def node(): - return Node(A) - - -def test_transform_property(node: Node): - P.transform_property(node) - section = node.docstring["Attributes"] - assert "p" in section - assert "f" in node - - -def test_get_type(node: Node): - assert P.get_type(node).name == "" - assert P.get_type(node["f"]).name == "str" - assert P.get_type(node["g"]).name == "int" - assert P.get_type(node["a"]).name == "(int, str)" - assert P.get_type(node["b"]).name == "str" - node["g"].docstring.sections[1] - - -def test_transform_class(node: Node): - P.transform(node) - section = node.docstring["Methods"] - q = A.__qualname__ - for name in "fgab": - assert name in section - item = section[name] - item.markdown = name * 3 - item.html.startswith(f'{name}') - section = node.docstring["Classes"] - assert "B" in section - item = section["B"].markdown == "BBB" - node = Node(A.B) - P.transform_class(node) - - -def test_transform_module(module): - node = Node(module) - P.transform(node, ["link"]) - q = module.__name__ - section = node.docstring["Functions"] - assert "add" in section - item = section["add"] - item.markdown.startswith("Returns") - item.html.startswith(f'add') - assert "gen" in section - section = node.docstring["Classes"] - assert "ExampleClass" in section - assert "ExampleDataClass" in section - - -def test_link_from_toc(): - from examples.styles.google import ExampleClass - - node = Node(ExampleClass) - assert len(node.docstring.sections) == 4 - P.transform(node) - assert len(node.docstring.sections) == 5 - assert "Methods" in node.docstring - section = node.docstring["Methods"] - html = section.items[0].html - assert '' in html diff --git a/tests_old/core/test_mro.py b/tests_old/core/test_mro.py deleted file mode 100644 index 22e66a5a..00000000 --- a/tests_old/core/test_mro.py +++ /dev/null @@ -1,39 +0,0 @@ -from examples import meta -from examples.meta import C, F -from mkapi.core.base import Docstring -from mkapi.core.docstring import parse_bases -from mkapi.core.inherit import inherit -from mkapi.core.node import get_node - - -def test_mro_docstring(): - doc = Docstring() - parse_bases(doc, C) - assert len(doc["Bases"].items) == 2 - assert doc["Bases"].items[0].type.markdown == "[examples.meta.B](!examples.meta.B)" - assert doc["Bases"].items[1].type.markdown == "[examples.meta.A](!examples.meta.A)" - - doc = Docstring() - parse_bases(doc, F) - assert len(doc["Bases"].items) == 2 - assert doc["Bases"].items[0].type.markdown == "[examples.meta.E](!examples.meta.E)" - assert doc["Bases"].items[1].type.markdown == "[examples.meta.D](!examples.meta.D)" - - -def test_mro_node(): - node = get_node(C) - assert len(node.members) == 2 - assert node.members[0].object.id == "examples.meta.A.f" - assert node.members[1].object.id == "examples.meta.C.g" - - -def test_mro_inherit(): - node = get_node(C) - inherit(node) - item = node.members[1].docstring["Parameters"].items[0] - assert item.description.markdown == "parameter." - - -def test_mro_module(): - node = get_node(meta) - assert len(node.members) == 6 From a7df7a645396ba01bc9dca03ae5e26cd5b621f4b Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 13 Jan 2024 23:07:53 +0900 Subject: [PATCH 091/148] New Object --- src/mkapi/docstrings.py | 15 +- src/mkapi/elements.py | 38 ++- src/mkapi/importlib.py | 421 +++++++++++++++++++++++++ src/mkapi/objects.py | 535 +++++++++----------------------- tests/conftest.py | 22 -- tests/elements/__init__.py | 0 tests/elements/conftest.py | 48 --- tests/elements/test_elements.py | 146 --------- tests/objects/test_arg.py | 58 ---- tests/objects/test_attr.py | 33 -- tests/objects/test_class.py | 23 +- tests/objects/test_inherit.py | 10 - tests/objects/test_object.py | 133 -------- tests/test_elements.py | 290 +++++++++++++++++ tests/test_importlib.py | 118 +++++++ tests/test_objects.py | 162 ++++++++++ 16 files changed, 1196 insertions(+), 856 deletions(-) create mode 100644 src/mkapi/importlib.py delete mode 100644 tests/conftest.py delete mode 100644 tests/elements/__init__.py delete mode 100644 tests/elements/conftest.py delete mode 100644 tests/elements/test_elements.py delete mode 100644 tests/objects/test_arg.py delete mode 100644 tests/objects/test_attr.py delete mode 100644 tests/objects/test_object.py create mode 100644 tests/test_elements.py create mode 100644 tests/test_importlib.py create mode 100644 tests/test_objects.py diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index f4f0a1d2..16462cc5 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -235,10 +235,11 @@ def iter_sections(doc: str, style: Style) -> Iterator[Section]: yield Section(name, type_, text_, items) -@dataclass(repr=False) -class Docstring(Item): +@dataclass +class Docstring: """Docstring class.""" + text: Text sections: list[Section] def __repr__(self) -> str: @@ -252,8 +253,10 @@ def get(self, name: str) -> Section | None: return get_by_name(self.sections, name) -def parse(doc: str, style: Style | None = None) -> Docstring: +def parse(doc: str | None, style: Style | None = None) -> Docstring: """Return a [Docstring] instance.""" + if not doc: + return Docstring(Text(None), []) doc = add_fence(doc) style = style or get_style(doc) sections = list(iter_sections(doc, style)) @@ -266,7 +269,7 @@ def parse(doc: str, style: Style | None = None) -> Docstring: text = Text(text) else: text = Text(None) - return Docstring("", Type(None), text, sections) + return Docstring(text, sections) def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: @@ -325,7 +328,5 @@ def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: elif is_named_section: sections.append(section) sections.extend(s for s in b.sections if not s.name) - name_ = a.name or b.name - type_ = a.type or b.type text = a.text or b.text - return Docstring(name_, type_, text, sections) + return Docstring(text, sections) diff --git a/src/mkapi/elements.py b/src/mkapi/elements.py index 5e404a75..d60037c2 100644 --- a/src/mkapi/elements.py +++ b/src/mkapi/elements.py @@ -7,7 +7,6 @@ import mkapi.ast import mkapi.dataclasses -from mkapi import docstrings from mkapi.ast import is_property from mkapi.utils import iter_parent_modulenames @@ -93,6 +92,23 @@ def create_returns(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Ret yield Return("", node.returns, Type(node.returns), Text(None)) +@dataclass(repr=False) +class Base(Element): + """Base class for [Class].""" + + node: ast.expr | None + + +def create_bases(node: ast.ClassDef) -> Iterator[Base]: + """Yield [Raise] instances.""" + for base in node.bases: + if isinstance(base, ast.Subscript): + name = ast.unparse(base.value) + else: + name = ast.unparse(base) + yield Base(name, base, Type(base), Text(None)) + + @dataclass(repr=False) class Import: """Import class for [Module].""" @@ -107,7 +123,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" -def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: +def _create_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: """Yield [Import] instances.""" for alias in node.names: if isinstance(node, ast.Import): @@ -123,6 +139,13 @@ def iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: yield Import(node, name, fullname, from_, node.level) +def create_imports(node: ast.Module) -> Iterator[Import]: + """Yield [Import] instances.""" + for child in mkapi.ast.iter_child_nodes(node): + if isinstance(child, ast.Import | ast.ImportFrom): + yield from _create_imports(child) + + @dataclass(repr=False) class Attribute(Element): """Atrribute class for [Module] or [Class].""" @@ -161,9 +184,18 @@ def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: def _attribute_type_text(type_: ast.expr | None, text: str | None) -> tuple[Type, Text]: if not text: return Type(type_), Text(None) - type_doc, text = docstrings.split_without_name(text, "google") + type_doc, text = _split_without_name(text) if not type_ and type_doc: # ex. 'list(str)' -> 'list[str]' for ast.expr type_doc = type_doc.replace("(", "[").replace(")", "]") type_ = mkapi.ast.create_expr(type_doc) return Type(type_), Text(text) + + +def _split_without_name(text: str) -> tuple[str, str]: + """Return a tuple of (type, text) for Returns or Yields section.""" + lines = text.split("\n") + if ":" in lines[0]: + type_, text_ = lines[0].split(":", maxsplit=1) + return type_.strip(), "\n".join([text_.strip(), *lines[1:]]) + return "", text diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py new file mode 100644 index 00000000..9685cf2c --- /dev/null +++ b/src/mkapi/importlib.py @@ -0,0 +1,421 @@ +# """Object module.""" +# from __future__ import annotations + +# import ast +# import re +# from dataclasses import dataclass, field +# from typing import TYPE_CHECKING, ClassVar + +# import mkapi.ast +# import mkapi.dataclasses +# from mkapi import docstrings +# from mkapi.elements import ( +# Text, +# Type, +# create_attributes, +# create_imports, +# create_parameters, +# create_raises, +# create_returns, +# ) +# from mkapi.utils import ( +# del_by_name, +# get_by_name, +# get_module_path, +# iter_parent_modulenames, +# unique_names, +# ) + +# if TYPE_CHECKING: +# from collections.abc import Iterator +# from typing import Self + +# from mkapi.docstrings import Docstring, Item, Section +# from mkapi.elements import Attribute, Import, Parameter, Raise, Return + + +# @dataclass +# class Object: +# """Object class for class or function.""" + +# node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef +# name: str +# module: Module = field(init=False) +# doc: Docstring +# parameters: list[Parameter] +# raises: list[Raise] +# qualname: str = field(init=False) +# fullname: str = field(init=False) + +# def __post_init__(self) -> None: +# if not Module.current: +# raise NotImplementedError +# self.module = Module.current +# qualname = Class.classnames[-1] +# self.qualname = f"{qualname}.{self.name}" if qualname else self.name +# self.fullname = f"{self.module.name}.{self.qualname}" +# objects[self.fullname] = self # type:ignore + +# def __repr__(self) -> str: +# return f"{self.__class__.__name__}({self.name})" + +# def get_source(self, maxline: int | None = None) -> str | None: +# """Return the source code segment.""" +# if (module := self.module) and (source := module.source): +# start, stop = self.node.lineno - 1, self.node.end_lineno +# return "\n".join(source.split("\n")[start:stop][:maxline]) +# return None + +# def get_parameter(self, name: str) -> Parameter | None: +# """Return a [Parameter] instance by the name.""" +# return get_by_name(self.parameters, name) + +# def get_raise(self, name: str) -> Raise | None: +# """Return a [Raise] instance by the name.""" +# return get_by_name(self.raises, name) + +# @property +# def id(self) -> str: # noqa: A003, D102 +# return self.fullname + + +# objects: dict[str, Class | Function | Module | None] = {} + + +# @dataclass(repr=False) +# class Function(Object): +# """Function class.""" + +# node: ast.FunctionDef | ast.AsyncFunctionDef +# returns: list[Return] + + +# def create_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: +# """Return a [Function] instance.""" +# doc = docstrings.parse(ast.get_docstring(node)) +# parameters = list(create_parameters(node)) +# raises = list(create_raises(node)) +# returns = list(create_returns(node)) +# return Function(node, node.name, doc, parameters, raises, returns) + + +# @dataclass(repr=False) +# class Class(Object): +# """Class class.""" + +# node: ast.ClassDef +# attributes: list[Attribute] +# classes: list[Class] = field(default_factory=list, init=False) +# functions: list[Function] = field(default_factory=list, init=False) +# bases: list[Class] = field(default_factory=list, init=False) +# classnames: ClassVar[list[str | None]] = [None] + +# def get_attribute(self, name: str) -> Attribute | None: +# """Return an [Attribute] instance by the name.""" +# return get_by_name(self.attributes, name) + +# def get_class(self, name: str) -> Class | None: +# """Return a [Class] instance by the name.""" +# return get_by_name(self.classes, name) + +# def get_function(self, name: str) -> Function | None: +# """Return a [Function] instance by the name.""" +# return get_by_name(self.functions, name) + +# def iter_bases(self) -> Iterator[Class]: +# """Yield base classes including self.""" +# for base in self.bases: +# yield from base.iter_bases() +# yield self + + +# def create_class(node: ast.ClassDef) -> Class: +# """Return a [Class] instance.""" +# name = node.name +# doc = docstrings.parse(ast.get_docstring(node)) +# attributes = list(create_attributes(node)) +# cls = Class(node, name, doc, [], [], attributes) +# qualname = f"{Class.classnames[-1]}.{name}" if Class.classnames[-1] else name +# Class.classnames.append(qualname) +# for child in mkapi.ast.iter_child_nodes(node): +# if isinstance(child, ast.ClassDef): +# cls.classes.append(create_class(child)) +# elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): +# func = create_function(child) +# if not get_by_name(cls.attributes, func.name): # for property +# cls.functions.append(func) +# Class.classnames.pop() +# return cls + + +# @dataclass +# class Module: +# """Module class.""" + +# node: ast.Module +# name: str +# doc: Docstring +# imports: list[Import] +# attributes: list[Attribute] +# classes: list[Class] = field(default_factory=list, init=False) +# functions: list[Function] = field(default_factory=list, init=False) +# source: str | None = None +# kind: str | None = None +# current: ClassVar[Self | None] = None + +# def __repr__(self) -> str: +# return f"{self.__class__.__name__}({self.name})" + +# def get_import(self, name: str) -> Import | None: +# """Return an [Import] instance by the name.""" +# return get_by_name(self.imports, name) + +# def get_attribute(self, name: str) -> Attribute | None: +# """Return an [Attribute] instance by the name.""" +# return get_by_name(self.attributes, name) + +# def get_class(self, name: str) -> Class | None: +# """Return an [Class] instance by the name.""" +# return get_by_name(self.classes, name) + +# def get_function(self, name: str) -> Function | None: +# """Return an [Function] instance by the name.""" +# return get_by_name(self.functions, name) + +# def get_fullname(self, name: str | None = None) -> str | None: +# """Return the fullname of the module.""" +# if not name: +# return self.name +# if obj := self.get_member(name): +# return obj.fullname +# if "." in name: +# name, attr = name.rsplit(".", maxsplit=1) +# if import_ := self.get_import(name): # noqa: SIM102 +# if module := load_module(import_.fullname): +# return module.get_fullname(attr) +# return None + +# def get_source(self, maxline: int | None = None) -> str | None: +# """Return the source of the module.""" +# if not self.source: +# return None +# return "\n".join(self.source.split("\n")[:maxline]) + +# def get_member(self, name: str) -> Import | Class | Function | None: +# """Return a member instance by the name.""" +# if obj := self.get_import(name): +# return obj +# if obj := self.get_class(name): +# return obj +# if obj := self.get_function(name): +# return obj +# return None + +# # def set_markdown(self) -> None: +# # """Set markdown with link form.""" +# # for type_ in self.types: +# # type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) +# # for text in self.texts: +# # text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) + +# # def _get_link_type(self, name: str) -> str: +# # if fullname := self.get_fullname(name): +# # return get_link(name, fullname) +# # return name + +# # def _get_link_text(self, match: re.Match) -> str: +# # name = match.group(1) +# # if fullname := self.get_fullname(name): +# # return get_link(name, fullname) +# # return match.group() + + +# def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: +# """Return a [Module] instance from an [ast.Module] node.""" +# doc = docstrings.parse(ast.get_docstring(node)) +# imports = [] +# for import_ in create_imports(node): +# if import_.level: +# prefix = ".".join(name.split(".")[: import_.level]) +# import_.fullname = f"{prefix}.{import_.fullname}" +# imports.append(import_) +# attributes = list(create_attributes(node)) +# module = Module(node, name, doc, imports, attributes, None, None) +# Module.current = module +# for child in mkapi.ast.iter_child_nodes(node): +# if isinstance(child, ast.ClassDef): +# module.classes.append(create_class(child)) +# elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): +# module.functions.append(create_function(child)) +# Module.current = None +# return module + + +# def get_object(fullname: str) -> Module | Class | Function | None: +# """Return an [Object] instance by the fullname.""" +# if fullname in objects: +# return objects[fullname] +# for modulename in iter_parent_modulenames(fullname): +# if load_module(modulename) and fullname in objects: +# return objects[fullname] +# objects[fullname] = None +# return None + + +# LINK_PATTERN = re.compile(r"(? str: +# """Return a markdown link.""" +# return f"[{name}][__mkapi__.{fullname}]" + + +# modules: dict[str, Module | None] = {} + + +# def load_module(name: str) -> Module | None: +# """Return a [Module] instance by the name.""" +# if name in modules: +# return modules[name] +# if not (path := get_module_path(name)): +# modules[name] = None +# return None +# with path.open("r", encoding="utf-8") as f: +# source = f.read() +# module = load_module_from_source(source, name) +# module.kind = "package" if path.stem == "__init__" else "module" +# modules[name] = module +# return module + + +# def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: +# """Return a [Module] instance from a source string.""" +# node = ast.parse(source) +# module = create_module_from_node(node, name) +# module.source = source +# return module + + +# # def _postprocess(obj: Module | Class) -> None: +# # _merge_docstring(obj) +# # for func in obj.functions: +# # _merge_docstring(func) +# # for cls in obj.classes: +# # _postprocess(cls) +# # _postprocess_class(cls) + + +# # def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: +# # if not obj.type and item.type: +# # # ex. list(str) -> list[str] +# # type_ = item.type.replace("(", "[").replace(")", "]") +# # obj.type = Type(mkapi.ast.create_expr(type_)) +# # obj.text = Text(item.text) # Does item.text win? + + +# # def _new( +# # cls: type[Attribute | Parameter | Raise], +# # name: str, +# # ) -> Attribute | Parameter | Raise: +# # args = (None, name, None, None) +# # if cls is Attribute: +# # return Attribute(*args, None) +# # if cls is Parameter: +# # return Parameter(*args, None, None) +# # if cls is Raise: +# # return Raise(*args) +# # raise NotImplementedError + + +# # def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: +# # names = unique_names(attrs, items) +# # attrs_ = [] +# # for name in names: +# # if not (attr := get_by_name(attrs, name)): +# # attr = _new(cls, name) +# # attrs_.append(attr) +# # if not (item := get_by_name(items, name)): +# # continue +# # _merge_item(attr, item) # type: ignore +# # return attrs_ + + +# # def _merge_docstring(obj: Module | Class | Function) -> None: +# # """Merge [Object] and [Docstring].""" +# # if not obj.text: +# # return +# # sections: list[Section] = [] +# # for section in docstrings.parse(obj.text.str): +# # if section.name == "Attributes" and isinstance(obj, Module | Class): +# # obj.attributes = _merge_items(Attribute, obj.attributes, section.items) +# # elif section.name == "Parameters" and isinstance(obj, Class | Function): +# # obj.parameters = _merge_items(Parameter, obj.parameters, section.items) +# # elif section.name == "Raises" and isinstance(obj, Class | Function): +# # obj.raises = _merge_items(Raise, obj.raises, section.items) +# # elif section.name in ["Returns", "Yields"] and isinstance(obj, Function): +# # _merge_item(obj.returns, section) +# # obj.returns.name = section.name +# # else: +# # sections.append(section) + + +# # ATTRIBUTE_ORDER_DICT = { +# # ast.AnnAssign: 1, +# # ast.Assign: 2, +# # ast.FunctionDef: 3, +# # ast.TypeAlias: 4, +# # } + + +# # def _attribute_order(attr: Attribute) -> int: +# # if not attr.node: +# # return 0 +# # return ATTRIBUTE_ORDER_DICT.get(type(attr.node), 10) + + +# def _iter_base_classes(cls: Class) -> Iterator[Class]: +# """Yield base classes. + +# This function is called in postprocess for setting base classes. +# """ +# if not cls.module: +# return +# for node in cls.node.bases: +# base_name = next(mkapi.ast.iter_identifiers(node)) +# base_fullname = cls.module.get_fullname(base_name) +# if not base_fullname: +# continue +# base = get_object(base_fullname) +# if base and isinstance(base, Class): +# yield base + + +# def _inherit(cls: Class, name: str) -> None: +# # TODO: fix InitVar, ClassVar for dataclasses. +# members = {} +# for base in cls.bases: +# for member in getattr(base, name): +# members[member.name] = member +# for member in getattr(cls, name): +# members[member.name] = member +# setattr(cls, name, list(members.values())) + + +# # def _postprocess_class(cls: Class) -> None: +# # cls.bases = list(_iter_base_classes(cls)) +# # for name in ["attributes", "functions", "classes"]: +# # _inherit(cls, name) +# # if init := cls.get_function("__init__"): +# # cls.parameters = init.parameters +# # cls.raises = init.raises +# # # cls.docstring = docstrings.merge(cls.docstring, init.docstring) +# # cls.attributes.sort(key=_attribute_order) +# # del_by_name(cls.functions, "__init__") +# # if mkapi.dataclasses.is_dataclass(cls): +# # for attr, kind in mkapi.dataclasses.iter_parameters(cls): +# # args = (None, attr.name, None, None, attr.default, kind) +# # parameter = Parameter(*args) +# # parameter.text = attr.text +# # parameter.type = attr.type +# # parameter.module = attr.module +# # cls.parameters.append(parameter) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 89554033..364bf093 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -2,51 +2,53 @@ from __future__ import annotations import ast -import importlib -import importlib.util -import re -from dataclasses import InitVar, dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING import mkapi.ast import mkapi.dataclasses from mkapi import docstrings -from mkapi.elements import Text, Type -from mkapi.utils import ( - del_by_name, - get_by_name, - get_module_path, - iter_parent_modulenames, - unique_names, +from mkapi.docstrings import Docstring +from mkapi.elements import ( + Text, + create_attributes, + create_bases, + create_imports, + create_parameters, + create_raises, + create_returns, ) +from mkapi.utils import get_by_name if TYPE_CHECKING: from collections.abc import Iterator - from inspect import _ParameterKind - from typing import Self - from mkapi.docstrings import Docstring, Item, Section + from mkapi.elements import Attribute, Base, Import, Parameter, Raise, Return @dataclass class Object: - """Object base class.""" + """Object class for class or function.""" - node: ast.AST + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef name: str module: Module - text: Text - type: Type # noqa: A003 - - def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: - self.module = Module.current - self.text = Text(_text) if _text else None - self.type = Type(_type) if _type else None - if self.module and self.text: - self.module.add_text(self.text) - if self.module and self.type: - self.module.add_type(self.type) + parent: Object | None + doc: Docstring + classes: list[Class] + functions: list[Function] + parameters: list[Parameter] + raises: list[Raise] + qualname: str = field(init=False) + fullname: str = field(init=False) + + def __post_init__(self) -> None: + if self.parent: + self.qualname = f"{self.parent.qualname}.{self.name}" + else: + self.qualname = self.name + self.fullname = f"{self.module.name}.{self.qualname}" + objects[self.fullname] = self # type:ignore def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -58,53 +60,13 @@ def get_source(self, maxline: int | None = None) -> str | None: return "\n".join(source.split("\n")[start:stop][:maxline]) return None - def iter_types(self) -> Iterator[Type]: - """Yield [Type] instances.""" - if self.type: - yield self.type - - def iter_texts(self) -> Iterator[Text]: - """Yield [Text] instances.""" - if self.text: - yield self.text - - -objects: dict[str, Attribute | Class | Function | Module | None] = {} - - -@dataclass(repr=False) -class Member(Object): - """Member class for [Attribute], [Function], [Class], and [Module].""" - - qualname: str = field(init=False) - fullname: str = field(init=False) - - def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: - super().__post_init__(_text, _type) - qualname = Class.classnames[-1] - self.qualname = f"{qualname}.{self.name}" if qualname else self.name - if self.module: - self.fullname = f"{self.module.name}.{self.qualname}" - else: - self.fullname = f"{self.qualname}" - objects[self.fullname] = self # type:ignore - - # def iter_members(self) -> Iterator[Member]: - # """Yield [Member] instances.""" - # yield from [] - - @property - def id(self) -> str: # noqa: A003, D102 - return self.fullname - - -@dataclass(repr=False) -class Callable(Member): - """Callable class for [Class] and [Function].""" + def get_class(self, name: str) -> Class | None: + """Return a [Class] instance by the name.""" + return get_by_name(self.classes, name) - node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef - parameters: list[Parameter] - raises: list[Raise] + def get_function(self, name: str) -> Function | None: + """Return a [Function] instance by the name.""" + return get_by_name(self.functions, name) def get_parameter(self, name: str) -> Parameter | None: """Return a [Parameter] instance by the name.""" @@ -115,143 +77,89 @@ def get_raise(self, name: str) -> Raise | None: return get_by_name(self.raises, name) -def _callable_args( - node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, -) -> tuple[str | None, None, list[Parameter], list[Raise]]: - text = ast.get_docstring(node) - if isinstance(node, ast.ClassDef): - return text, None, [], [] - parameters = list(create_parameters(node)) - raises = list(create_raises(node)) - return text, None, parameters, raises +objects: dict[str, Class | Function | None] = {} @dataclass(repr=False) -class Function(Callable): +class Function(Object): """Function class.""" node: ast.FunctionDef | ast.AsyncFunctionDef - returns: Return + returns: list[Return] -def get_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: +def create_function( + node: ast.FunctionDef | ast.AsyncFunctionDef, + module: Module | None = None, + parent: Object | None = None, +) -> Function: """Return a [Function] instance.""" - return Function(node, node.name, *_callable_args(node), create_return(node)) + module = module or _create_empty_module() + doc = docstrings.parse(ast.get_docstring(node)) + parameters = list(create_parameters(node)) + raises = list(create_raises(node)) + returns = list(create_returns(node)) + args = ([], [], parameters, raises, returns) + func = Function(node, node.name, module, parent, doc, *args) + for child in mkapi.ast.iter_child_nodes(node): + if isinstance(child, ast.ClassDef): + func.classes.append(create_class(child, module, func)) + elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + func.functions.append(create_function(child, module, func)) + return func @dataclass(repr=False) -class Class(Callable): +class Class(Object): """Class class.""" node: ast.ClassDef - attributes: list[Attribute] = field(default_factory=list, init=False) - classes: list[Class] = field(default_factory=list, init=False) - functions: list[Function] = field(default_factory=list, init=False) - bases: list[Class] = field(default_factory=list, init=False) - classnames: ClassVar[list[str | None]] = [None] - - def add_member(self, member: Attribute | Class | Function | Import) -> None: - """Add a member.""" - if isinstance(member, Attribute): - self.attributes.append(member) - elif isinstance(member, Class): - self.classes.append(member) - elif isinstance(member, Function): - self.functions.append(member) + attributes: list[Attribute] + bases: list[Base] def get_attribute(self, name: str) -> Attribute | None: """Return an [Attribute] instance by the name.""" return get_by_name(self.attributes, name) - def get_class(self, name: str) -> Class | None: - """Return a [Class] instance by the name.""" - return get_by_name(self.classes, name) - - def get_function(self, name: str) -> Function | None: - """Return a [Function] instance by the name.""" - return get_by_name(self.functions, name) - - def iter_bases(self) -> Iterator[Class]: - """Yield base classes including self.""" - for base in self.bases: - yield from base.iter_bases() - yield self - -def create_class(node: ast.ClassDef) -> Class: +def create_class( + node: ast.ClassDef, + module: Module | None = None, + parent: Object | None = None, +) -> Class: """Return a [Class] instance.""" name = node.name - cls = Class(node, node.name, *_callable_args(node)) - qualname = f"{Class.classnames[-1]}.{name}" if Class.classnames[-1] else name - Class.classnames.append(qualname) - for member in create_members(node): - cls.add_member(member) - Class.classnames.pop() - return cls - - -def create_members( - node: ast.ClassDef | ast.Module, -) -> Iterator[Import | Attribute | Class | Function]: - """Yield created members.""" + module = module or _create_empty_module() + doc = docstrings.parse(ast.get_docstring(node)) + attributes = list(create_attributes(node)) + bases = list(create_bases(node)) + args = ([], [], [], [], attributes, bases) + cls = Class(node, name, module, parent, doc, *args) for child in mkapi.ast.iter_child_nodes(node): - if isinstance(child, ast.FunctionDef): # noqa: SIM102 - if mkapi.ast.is_property(child.decorator_list): - yield create_attribute_from_property(child) - continue - if isinstance(child, ast.Import | ast.ImportFrom): - yield from iter_imports(child) - elif isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): - attr = create_attribute(child) - if attr.name: - yield attr - elif isinstance(child, ast.ClassDef): - yield create_class(child) - elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): - yield get_function(child) + if isinstance(child, ast.ClassDef): + cls.classes.append(create_class(child, module, cls)) + elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): # noqa: SIM102 + if not cls.get_attribute(child.name): # for property + cls.functions.append(create_function(child, module, cls)) + return cls -@dataclass(repr=False) -class Module(Member): +@dataclass +class Module: """Module class.""" - node: ast.Module - imports: list[Import] = field(default_factory=list, init=False) - attributes: list[Attribute] = field(default_factory=list, init=False) + node: ast.Module | None + name: str + doc: Docstring + imports: list[Import] + attributes: list[Attribute] classes: list[Class] = field(default_factory=list, init=False) functions: list[Function] = field(default_factory=list, init=False) - types: list[Type] = field(default_factory=list, init=False) - texts: list[Text] = field(default_factory=list, init=False) source: str | None = None kind: str | None = None - current: ClassVar[Self | None] = None - - def __post_init__(self, _text: str | None, _type: ast.expr | None) -> None: - super().__post_init__(_text, _type) - self.qualname = self.fullname = self.name - modules[self.name] = self - - def add_type(self, type_: Type) -> None: - """Add a [Type] instance.""" - self.types.append(type_) - - def add_text(self, text: Text) -> None: - """Add a [Text] instance.""" - self.texts.append(text) - - def add_member(self, member: Import | Attribute | Class | Function) -> None: - """Add a member instance.""" - if isinstance(member, Import): - if member.level: - prefix = ".".join(self.name.split(".")[: member.level]) - member.fullname = f"{prefix}.{member.fullname}" - self.imports.append(member) - elif isinstance(member, Attribute): - self.attributes.append(member) - elif isinstance(member, Class): - self.classes.append(member) - elif isinstance(member, Function): - self.functions.append(member) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name})" def get_import(self, name: str) -> Import | None: """Return an [Import] instance by the name.""" @@ -269,235 +177,72 @@ def get_function(self, name: str) -> Function | None: """Return an [Function] instance by the name.""" return get_by_name(self.functions, name) - def get_member(self, name: str) -> Import | Attribute | Class | Function | None: + def get_source(self, maxline: int | None = None) -> str | None: + """Return the source of the module.""" + if not self.source: + return None + return "\n".join(self.source.split("\n")[:maxline]) + + def get_member(self, name: str) -> Import | Class | Function | None: """Return a member instance by the name.""" if obj := self.get_import(name): return obj - if obj := self.get_attribute(name): - return obj if obj := self.get_class(name): return obj if obj := self.get_function(name): return obj return None - def get_fullname(self, name: str | None = None) -> str | None: - """Return the fullname of the module.""" - if not name: - return self.name - if obj := self.get_member(name): - return obj.fullname - if "." in name: - name, attr = name.rsplit(".", maxsplit=1) - if import_ := self.get_import(name): # noqa: SIM102 - if module := load_module(import_.fullname): - return module.get_fullname(attr) - return None - - def get_source(self, maxline: int | None = None) -> str | None: - """Return the source of the module.""" - if not self.source: - return None - return "\n".join(self.source.split("\n")[:maxline]) - - def set_markdown(self) -> None: - """Set markdown with link form.""" - for type_ in self.types: - type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) - for text in self.texts: - text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) - - def _get_link_type(self, name: str) -> str: - if fullname := self.get_fullname(name): - return get_link(name, fullname) - return name - - def _get_link_text(self, match: re.Match) -> str: - name = match.group(1) - if fullname := self.get_fullname(name): - return get_link(name, fullname) - return match.group() - - -LINK_PATTERN = re.compile(r"(? str: - """Return a markdown link.""" - return f"[{name}][__mkapi__.{fullname}]" - - -modules: dict[str, Module | None] = {} - - -def load_module(name: str) -> Module | None: - """Return a [Module] instance by the name.""" - if name in modules: - return modules[name] - if not (path := get_module_path(name)): - modules[name] = None - return None - with path.open("r", encoding="utf-8") as f: - source = f.read() - module = load_module_from_source(source, name) - module.kind = "package" if path.stem == "__init__" else "module" - return module - - -def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: - """Return a [Module] instance from a source string.""" - node = ast.parse(source) - module = create_module_from_node(node, name) - module.source = source - return module - -def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: +def create_module(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from an [ast.Module] node.""" - text = ast.get_docstring(node) - module = Module(node, name, text, None) - if Module.current is not None: - raise NotImplementedError - Module.current = module - for member in create_members(node): - module.add_member(member) - _postprocess(module) - Module.current = None - module.set_markdown() + doc = docstrings.parse(ast.get_docstring(node)) + imports = [] + for import_ in create_imports(node): + if import_.level: + names = name.split(".") + prefix = ".".join(name.split(".")[: len(names) - import_.level + 1]) + import_.fullname = f"{prefix}.{import_.fullname}" + imports.append(import_) + attributes = list(create_attributes(node)) + module = Module(node, name, doc, imports, attributes, None, None) + for child in mkapi.ast.iter_child_nodes(node): + if isinstance(child, ast.ClassDef): + module.classes.append(create_class(child, module)) + elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + module.functions.append(create_function(child, module)) return module -def get_object(fullname: str) -> Module | Class | Function | Attribute | None: - """Return an [Object] instance by the fullname.""" - if fullname in objects: - return objects[fullname] - for modulename in iter_parent_modulenames(fullname): - if load_module(modulename) and fullname in objects: - return objects[fullname] - objects[fullname] = None - return None +def _create_empty_module() -> Module: + name = "__mkapi__" + doc = Docstring(Text(None), []) + return Module(None, name, doc, [], [], None, None) -def _postprocess(obj: Module | Class) -> None: - _merge_docstring(obj) - for func in obj.functions: - _merge_docstring(func) +def iter_objects(obj: Module | Class | Function) -> Iterator[Class | Function]: + """Yield [Class] or [Function] instances.""" + if not isinstance(obj, Module): + yield obj for cls in obj.classes: - _postprocess(cls) - _postprocess_class(cls) - - -def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: - if not obj.type and item.type: - # ex. list(str) -> list[str] - type_ = item.type.replace("(", "[").replace(")", "]") - obj.type = Type(mkapi.ast.create_expr(type_)) - obj.text = Text(item.text) # Does item.text win? - - -def _new( - cls: type[Attribute | Parameter | Raise], - name: str, -) -> Attribute | Parameter | Raise: - args = (None, name, None, None) - if cls is Attribute: - return Attribute(*args, None) - if cls is Parameter: - return Parameter(*args, None, None) - if cls is Raise: - return Raise(*args) - raise NotImplementedError - - -def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: - names = unique_names(attrs, items) - attrs_ = [] - for name in names: - if not (attr := get_by_name(attrs, name)): - attr = _new(cls, name) - attrs_.append(attr) - if not (item := get_by_name(items, name)): - continue - _merge_item(attr, item) # type: ignore - return attrs_ - - -def _merge_docstring(obj: Module | Class | Function) -> None: - """Merge [Object] and [Docstring].""" - if not obj.text: - return - sections: list[Section] = [] - for section in docstrings.parse(obj.text.str): - if section.name == "Attributes" and isinstance(obj, Module | Class): - obj.attributes = _merge_items(Attribute, obj.attributes, section.items) - elif section.name == "Parameters" and isinstance(obj, Class | Function): - obj.parameters = _merge_items(Parameter, obj.parameters, section.items) - elif section.name == "Raises" and isinstance(obj, Class | Function): - obj.raises = _merge_items(Raise, obj.raises, section.items) - elif section.name in ["Returns", "Yields"] and isinstance(obj, Function): - _merge_item(obj.returns, section) - obj.returns.name = section.name - else: - sections.append(section) - - -ATTRIBUTE_ORDER_DICT = { - ast.AnnAssign: 1, - ast.Assign: 2, - ast.FunctionDef: 3, - ast.TypeAlias: 4, -} - - -def _attribute_order(attr: Attribute) -> int: - if not attr.node: - return 0 - return ATTRIBUTE_ORDER_DICT.get(type(attr.node), 10) - - -def _iter_base_classes(cls: Class) -> Iterator[Class]: - """Yield base classes. - - This function is called in postprocess for setting base classes. - """ - if not cls.module: - return - for node in cls.node.bases: - base_name = next(mkapi.ast.iter_identifiers(node)) - base_fullname = cls.module.get_fullname(base_name) - if not base_fullname: - continue - base = get_object(base_fullname) - if base and isinstance(base, Class): - yield base - - -def _inherit(cls: Class, name: str) -> None: - # TODO: fix InitVar, ClassVar for dataclasses. - members = {} - for base in cls.bases: - for member in getattr(base, name): - members[member.name] = member - for member in getattr(cls, name): - members[member.name] = member - setattr(cls, name, list(members.values())) - - -def _postprocess_class(cls: Class) -> None: - cls.bases = list(_iter_base_classes(cls)) - for name in ["attributes", "functions", "classes"]: - _inherit(cls, name) - if init := cls.get_function("__init__"): - cls.parameters = init.parameters - cls.raises = init.raises - # cls.docstring = docstrings.merge(cls.docstring, init.docstring) - cls.attributes.sort(key=_attribute_order) - del_by_name(cls.functions, "__init__") - if mkapi.dataclasses.is_dataclass(cls): - for attr, kind in mkapi.dataclasses.iter_parameters(cls): - args = (None, attr.name, None, None, attr.default, kind) - parameter = Parameter(*args) - parameter.text = attr.text - parameter.type = attr.type - parameter.module = attr.module - cls.parameters.append(parameter) + yield from iter_objects(cls) + for func in obj.functions: + yield from iter_objects(func) + + +def iter_elements( + obj: Module | Class | Function, +) -> Iterator[Attribute | Parameter | Raise | Return | Base]: + """Yield [Element] instances.""" + for obj_ in iter_objects(obj): + if obj_ is not obj: + yield from iter_elements(obj_) + if isinstance(obj, Function | Class): + yield from obj.parameters + yield from obj.raises + if isinstance(obj, Function): + yield from obj.returns + if isinstance(obj, Module | Class): + yield from obj.attributes + if isinstance(obj, Class): + yield from obj.bases diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 812cd4f2..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys -from pathlib import Path - -import pytest - -from mkapi.objects import load_module - - -@pytest.fixture(scope="module") -def google(): - path = str(Path(__file__).parent.parent) - if path not in sys.path: - sys.path.insert(0, str(path)) - return load_module("examples.styles.example_google") - - -@pytest.fixture(scope="module") -def numpy(): - path = str(Path(__file__).parent.parent) - if path not in sys.path: - sys.path.insert(0, str(path)) - return load_module("examples.styles.example_numpy") diff --git a/tests/elements/__init__.py b/tests/elements/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/elements/conftest.py b/tests/elements/conftest.py deleted file mode 100644 index e4ae57e9..00000000 --- a/tests/elements/conftest.py +++ /dev/null @@ -1,48 +0,0 @@ -import ast -import sys -from pathlib import Path - -import pytest - -from mkapi.ast import iter_child_nodes -from mkapi.utils import get_module_path - - -@pytest.fixture(scope="module") -def module(): - path = get_module_path("mkdocs.structure.files") - assert path - with path.open("r", encoding="utf-8") as f: - source = f.read() - return ast.parse(source) - - -def load_module(name): - path = str(Path(__file__).parent.parent) - if path not in sys.path: - sys.path.insert(0, str(path)) - path = get_module_path(name) - assert path - with path.open("r", encoding="utf-8") as f: - source = f.read() - return ast.parse(source) - - -@pytest.fixture(scope="module") -def google(): - return load_module("examples.styles.example_google") - - -@pytest.fixture(scope="module") -def get(google): - def get(name, *rest, node=google): - for child in iter_child_nodes(node): - if not isinstance(child, ast.FunctionDef | ast.ClassDef): - continue - if child.name == name: - if not rest: - return child - return get(*rest, node=child) - raise NameError - - return get diff --git a/tests/elements/test_elements.py b/tests/elements/test_elements.py deleted file mode 100644 index cd80e5e0..00000000 --- a/tests/elements/test_elements.py +++ /dev/null @@ -1,146 +0,0 @@ -import ast -from inspect import Parameter - -from mkapi.ast import iter_child_nodes -from mkapi.elements import ( - Element, - Import, - Text, - Type, - create_attributes, - create_parameters, - create_raises, - create_returns, - iter_imports, -) - - -def test_iter_import_nodes(module: ast.Module): - node = next(iter_child_nodes(module)) - assert isinstance(node, ast.ImportFrom) - assert len(node.names) == 1 - alias = node.names[0] - assert node.module == "__future__" - assert alias.name == "annotations" - assert alias.asname is None - - -def test_iter_import_nodes_alias(): - src = "import matplotlib.pyplot" - node = ast.parse(src).body[0] - assert isinstance(node, ast.Import) - x = list(iter_imports(node)) - assert len(x) == 2 - assert x[0].fullname == "matplotlib" - assert x[1].fullname == "matplotlib.pyplot" - src = "import matplotlib.pyplot as plt" - node = ast.parse(src).body[0] - assert isinstance(node, ast.Import) - x = list(iter_imports(node)) - assert len(x) == 1 - assert x[0].fullname == "matplotlib.pyplot" - assert x[0].name == "plt" - src = "from matplotlib import pyplot as plt" - node = ast.parse(src).body[0] - assert isinstance(node, ast.ImportFrom) - x = list(iter_imports(node)) - assert len(x) == 1 - assert x[0].fullname == "matplotlib.pyplot" - assert x[0].name == "plt" - - -def test_create_parameters(get): - func = get("function_with_pep484_type_annotations") - x = list(create_parameters(func)) - assert x[0].name == "param1" - assert isinstance(x[0].type.expr, ast.Name) - assert x[0].type.expr.id == "int" - assert x[1].name == "param2" - assert isinstance(x[1].type.expr, ast.Name) - assert x[1].type.expr.id == "str" - - func = get("module_level_function") - x = list(create_parameters(func)) - assert x[0].name == "param1" - assert x[0].type.expr is None - assert x[0].default is None - assert x[0].kind is Parameter.POSITIONAL_OR_KEYWORD - assert x[1].name == "param2" - assert x[1].type.expr is None - assert isinstance(x[1].default, ast.Constant) - assert x[1].default.value is None - assert x[1].kind is Parameter.POSITIONAL_OR_KEYWORD - assert x[2].name == "args" - assert x[2].type.expr is None - assert x[2].default is None - assert x[2].kind is Parameter.VAR_POSITIONAL - assert x[3].name == "kwargs" - assert x[3].type.expr is None - assert x[3].default is None - assert x[3].kind is Parameter.VAR_KEYWORD - - -def test_create_raises(get): - func = get("module_level_function") - x = next(create_raises(func)) - assert x.name == "ValueError" - assert x.text.str is None - assert isinstance(x.type.expr, ast.Name) - assert x.type.expr.id == "ValueError" - - -def test_create_returns(get): - func = get("function_with_pep484_type_annotations") - x = next(create_returns(func)) - assert x.name == "" - assert isinstance(x.type.expr, ast.Name) - assert x.type.expr.id == "bool" - - -def test_create_attributes(google, get): - x = list(create_attributes(google)) - assert x[0].name == "module_level_variable1" - assert x[0].type.expr is None - assert x[0].text.str is None - assert isinstance(x[0].default, ast.Constant) - assert x[0].default.value == 12345 - assert x[1].name == "module_level_variable2" - assert isinstance(x[1].type.expr, ast.Name) - assert x[1].type.expr.id == "int" - assert x[1].text.str - assert x[1].text.str.startswith("Module level") - assert x[1].text.str.endswith("by a colon.") - assert isinstance(x[1].default, ast.Constant) - assert x[1].default.value == 98765 - cls = get("ExamplePEP526Class") - x = list(create_attributes(cls)) - assert x[0].name == "attr1" - assert isinstance(x[0].type.expr, ast.Name) - assert x[0].type.expr.id == "str" - assert x[1].name == "attr2" - assert isinstance(x[1].type.expr, ast.Name) - assert x[1].type.expr.id == "int" - - -def test_create_attributes_from_property(get): - cls = get("ExampleClass") - x = list(create_attributes(cls)) - assert x[0].name == "readonly_property" - assert isinstance(x[0].type.expr, ast.Name) - assert x[0].type.expr.id == "str" - assert x[0].text.str - assert x[0].text.str.startswith("Properties should") - assert x[1].name == "readwrite_property" - assert isinstance(x[1].type.expr, ast.Subscript) - assert x[1].text.str - assert x[1].text.str.startswith("Properties with") - - -def test_repr(): - e = Element("abc", None, Type(None), Text(None)) - assert repr(e) == "Element(abc)" - src = "import matplotlib.pyplot as plt" - node = ast.parse(src).body[0] - assert isinstance(node, ast.Import) - x = list(iter_imports(node)) - assert repr(x[0]) == "Import(plt)" diff --git a/tests/objects/test_arg.py b/tests/objects/test_arg.py deleted file mode 100644 index c094d209..00000000 --- a/tests/objects/test_arg.py +++ /dev/null @@ -1,58 +0,0 @@ -import ast -from inspect import Parameter - -from mkapi.objects import create_parameters - - -def _get_args(source: str): - node = ast.parse(source).body[0] - assert isinstance(node, ast.FunctionDef) - return list(create_parameters(node)) - - -def test_get_parameters_1(): - args = _get_args("def f():\n pass") - assert not args - args = _get_args("def f(x):\n pass") - assert args[0].type is None - assert args[0].default is None - assert args[0].kind is Parameter.POSITIONAL_OR_KEYWORD - x = _get_args("def f(x=1):\n pass")[0] - assert isinstance(x.default, ast.Constant) - x = _get_args("def f(x:str='s'):\n pass")[0] - assert x.type - assert isinstance(x.type.expr, ast.Name) - assert x.type.expr.id == "str" - assert isinstance(x.default, ast.Constant) - assert x.default.value == "s" - x = _get_args("def f(x:'X'='s'):\n pass")[0] - assert x.type - assert isinstance(x.type.expr, ast.Constant) - assert x.type.expr.value == "X" - - -def test_get_parameters_2(): - x = _get_args("def f(x:tuple[int]=(1,)):\n pass")[0] - assert x.type - node = x.type.expr - assert isinstance(node, ast.Subscript) - assert isinstance(node.value, ast.Name) - assert node.value.id == "tuple" - assert isinstance(node.slice, ast.Name) - assert node.slice.id == "int" - assert isinstance(x.default, ast.Tuple) - assert isinstance(x.default.elts[0], ast.Constant) - assert x.default.elts[0].value == 1 - - -def test_get_parameters_3(): - x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] - assert x.type - node = x.type.expr - assert isinstance(node, ast.Subscript) - assert isinstance(node.value, ast.Name) - assert node.value.id == "tuple" - assert isinstance(node.slice, ast.Tuple) - assert node.slice.elts[0].id == "int" # type: ignore - assert node.slice.elts[1].id == "str" # type: ignore - assert isinstance(x.default, ast.Tuple) diff --git a/tests/objects/test_attr.py b/tests/objects/test_attr.py deleted file mode 100644 index 3367d27f..00000000 --- a/tests/objects/test_attr.py +++ /dev/null @@ -1,33 +0,0 @@ -import ast - -from mkapi.objects import Attribute, create_members - - -def _get_attributes(source: str): - node = ast.parse(source).body[0] - assert isinstance(node, ast.ClassDef) - return list(create_members(node)) - - -def test_get_attributes(): - src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" - x = _get_attributes(src)[0] - assert isinstance(x, Attribute) - assert x.type is None - assert isinstance(x.default, ast.Call) - assert ast.unparse(x.default.func) == "f.g" - assert x.text - assert x.text.str == "docstring" - src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" - assigns = _get_attributes(src) - x, y, z = assigns - assert isinstance(x, Attribute) - assert isinstance(y, Attribute) - assert isinstance(z, Attribute) - assert not x.text - assert x.default is None - assert y.text - assert y.text.str == "docstring\na" - assert not z.text - assert z.default is not None - assert list(assigns) == [x, y, z] diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py index 089a66f7..fed65247 100644 --- a/tests/objects/test_class.py +++ b/tests/objects/test_class.py @@ -1,4 +1,5 @@ -from mkapi.objects import Class, _iter_base_classes, get_object +from mkapi.objects import Class, _inherit, _iter_base_classes, get_object, load_module +from mkapi.utils import get_by_name def test_baseclasses(): @@ -23,3 +24,23 @@ def test_baseclasses(): assert base.name == "Config" assert base.qualname == "Config" assert base.fullname == "mkdocs.config.base.Config" + + +def test_iter_bases(): + module = load_module("mkapi.objects") + assert module + cls = module.get_class("Class") + assert cls + cls.bases = list(_iter_base_classes(cls)) + bases = cls.iter_bases() + assert next(bases).name == "Object" + assert next(bases).name == "Class" + + +def test_inherit(): + cls = get_object("mkapi.plugins.MkAPIConfig") + assert isinstance(cls, Class) + cls.bases = list(_iter_base_classes(cls)) + _inherit(cls, "attributes") + assert cls.bases[0].fullname == "mkdocs.config.base.Config" + assert get_by_name(cls.attributes, "config_file_path") diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py index 84687a4a..df1c1311 100644 --- a/tests/objects/test_inherit.py +++ b/tests/objects/test_inherit.py @@ -30,16 +30,6 @@ def test_inherit(): assert not get_by_name(p, "classnames") -def test_inherit_other_module(): - cls = get_object("mkapi.plugins.MkAPIConfig") - assert isinstance(cls, Class) - assert cls.bases[0].fullname == "mkdocs.config.base.Config" - a = cls.attributes - x = get_by_name(a, "config_file_path") - assert x - assert x.fullname == "mkdocs.config.base.Config.config_file_path" - - def test_inherit_other_module2(): cls = get_object("mkapi.plugins.MkAPIPlugin") assert isinstance(cls, Class) diff --git a/tests/objects/test_object.py b/tests/objects/test_object.py deleted file mode 100644 index 7d873f6c..00000000 --- a/tests/objects/test_object.py +++ /dev/null @@ -1,133 +0,0 @@ -import ast - -import pytest - -import mkapi.ast -import mkapi.objects -from mkapi.ast import iter_child_nodes -from mkapi.objects import ( - Class, - Function, - Module, - get_module_path, - get_object, - iter_imports, - load_module, - modules, - objects, -) - - -@pytest.fixture(scope="module") -def module(): - path = get_module_path("mkdocs.structure.files") - assert path - with path.open("r", encoding="utf-8") as f: - source = f.read() - return ast.parse(source) - - -# def test_not_found(): -# assert load_module("xxx") is None -# assert mkapi.objects.modules["xxx"] is None -# assert load_module("markdown") -# assert "markdown" in mkapi.objects.modules - - -# def test_repr(): -# module = load_module("mkapi") -# assert repr(module) == "Module(mkapi)" -# module = load_module("mkapi.objects") -# assert repr(module) == "Module(mkapi.objects)" -# obj = get_object("mkapi.objects.Object") -# assert repr(obj) == "Class(Object)" - - -# def test_load_module_source(): -# module = load_module("mkdocs.structure.files") -# assert module -# assert module.source -# assert "class File" in module.source -# module = load_module("mkapi.plugins") -# assert module -# cls = module.get_class("MkAPIConfig") -# assert cls -# assert cls.module is module -# src = cls.get_source() -# assert src -# assert src.startswith("class MkAPIConfig") -# src = module.get_source() -# assert src -# assert "MkAPIPlugin" in src - - -# def test_load_module_from_object(): -# module = load_module("mkdocs.structure.files") -# assert module -# c = module.classes[1] -# m = c.module -# assert module is m - - -# def test_fullname(google: Module): -# c = google.get_class("ExampleClass") -# assert isinstance(c, Class) -# f = c.get_function("example_method") -# assert isinstance(f, Function) -# assert c.fullname == "examples.styles.example_google.ExampleClass" -# name = "examples.styles.example_google.ExampleClass.example_method" -# assert f.fullname == name - - -# def test_cache(): -# modules.clear() -# objects.clear() -# module = load_module("mkapi.objects") -# c = get_object("mkapi.objects.Object") -# f = get_object("mkapi.objects.Module.get_class") -# assert c -# assert c.module is module -# assert f -# assert f.module is module -# c2 = get_object("mkapi.objects.Object") -# f2 = get_object("mkapi.objects.Module.get_class") -# assert c is c2 -# assert f is f2 - -# m1 = load_module("mkdocs.structure.files") -# m2 = load_module("mkdocs.structure.files") -# assert m1 is m2 -# modules.clear() -# m3 = load_module("mkdocs.structure.files") -# m4 = load_module("mkdocs.structure.files") -# assert m2 is not m3 -# assert m3 is m4 - - -# def test_module_kind(): -# module = load_module("mkapi") -# assert module -# assert module.kind == "package" -# module = load_module("mkapi.objects") -# assert module -# assert module.kind == "module" - - -# def test_get_fullname_with_attr(): -# module = load_module("mkapi.plugins") -# assert module -# name = module.get_fullname("config_options.Type") -# assert name == "mkdocs.config.config_options.Type" -# assert not module.get_fullname("config_options.A") - - -# def test_iter_bases(): -# module = load_module("mkapi.objects") -# assert module -# cls = module.get_class("Class") -# assert cls -# bases = cls.iter_bases() -# assert next(bases).name == "Object" -# assert next(bases).name == "Member" -# assert next(bases).name == "Callable" -# assert next(bases).name == "Class" diff --git a/tests/test_elements.py b/tests/test_elements.py new file mode 100644 index 00000000..d866fe2d --- /dev/null +++ b/tests/test_elements.py @@ -0,0 +1,290 @@ +import ast +import sys +from inspect import Parameter +from pathlib import Path + +import pytest + +from mkapi.ast import iter_child_nodes +from mkapi.elements import ( + Attribute, + Element, + Text, + Type, + _create_imports, + create_attributes, + create_bases, + create_parameters, + create_raises, + create_returns, +) +from mkapi.utils import get_module_path + + +def _get_args(source: str): + node = ast.parse(source).body[0] + assert isinstance(node, ast.FunctionDef) + return list(create_parameters(node)) + + +def test_create_parameters(): + args = _get_args("def f():\n pass") + assert not args + args = _get_args("def f(x):\n pass") + assert args[0].type.expr is None + assert args[0].default is None + assert args[0].kind is Parameter.POSITIONAL_OR_KEYWORD + x = _get_args("def f(x=1):\n pass")[0] + assert isinstance(x.default, ast.Constant) + x = _get_args("def f(x:str='s'):\n pass")[0] + assert x.type.expr + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "str" + assert isinstance(x.default, ast.Constant) + assert x.default.value == "s" + x = _get_args("def f(x:'X'='s'):\n pass")[0] + assert x.type + assert isinstance(x.type.expr, ast.Constant) + assert x.type.expr.value == "X" + + +def test_create_parameters_tuple(): + x = _get_args("def f(x:tuple[int]=(1,)):\n pass")[0] + assert x.type + node = x.type.expr + assert isinstance(node, ast.Subscript) + assert isinstance(node.value, ast.Name) + assert node.value.id == "tuple" + assert isinstance(node.slice, ast.Name) + assert node.slice.id == "int" + assert isinstance(x.default, ast.Tuple) + assert isinstance(x.default.elts[0], ast.Constant) + assert x.default.elts[0].value == 1 + + +def test_create_parameters_slice(): + x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] + assert x.type + node = x.type.expr + assert isinstance(node, ast.Subscript) + assert isinstance(node.value, ast.Name) + assert node.value.id == "tuple" + assert isinstance(node.slice, ast.Tuple) + assert node.slice.elts[0].id == "int" # type: ignore + assert node.slice.elts[1].id == "str" # type: ignore + assert isinstance(x.default, ast.Tuple) + + +def _get_attributes(source: str): + node = ast.parse(source).body[0] + assert isinstance(node, ast.ClassDef) + return list(create_attributes(node)) + + +def test_get_attributes(): + src = "class A:\n x=f.g(1,p='2')\n '''docstring'''" + x = _get_attributes(src)[0] + assert isinstance(x, Attribute) + assert x.type.expr is None + assert isinstance(x.default, ast.Call) + assert ast.unparse(x.default.func) == "f.g" + assert x.text.str == "docstring" + src = "class A:\n x:X\n y:y\n '''docstring\n a'''\n z=0" + assigns = _get_attributes(src) + x, y, z = assigns + assert isinstance(x, Attribute) + assert isinstance(y, Attribute) + assert isinstance(z, Attribute) + assert not x.text.str + assert x.default is None + assert y.text.str == "docstring\na" + assert not z.text.str + assert isinstance(z.default, ast.Constant) + assert z.default.value == 0 + assert list(assigns) == [x, y, z] + + +@pytest.fixture(scope="module") +def module(): + path = get_module_path("mkdocs.structure.files") + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +def test_iter_import_nodes(module: ast.Module): + node = next(iter_child_nodes(module)) + assert isinstance(node, ast.ImportFrom) + assert len(node.names) == 1 + alias = node.names[0] + assert node.module == "__future__" + assert alias.name == "annotations" + assert alias.asname is None + + +def test_iter_import_nodes_alias(): + src = "import matplotlib.pyplot" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(_create_imports(node)) + assert len(x) == 2 + assert x[0].fullname == "matplotlib" + assert x[1].fullname == "matplotlib.pyplot" + src = "import matplotlib.pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(_create_imports(node)) + assert len(x) == 1 + assert x[0].fullname == "matplotlib.pyplot" + assert x[0].name == "plt" + src = "from matplotlib import pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.ImportFrom) + x = list(_create_imports(node)) + assert len(x) == 1 + assert x[0].fullname == "matplotlib.pyplot" + assert x[0].name == "plt" + + +def load_module(name): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + path = get_module_path(name) + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +@pytest.fixture(scope="module") +def google(): + return load_module("examples.styles.example_google") + + +@pytest.fixture(scope="module") +def get(google): + def get(name, *rest, node=google): + for child in iter_child_nodes(node): + if not isinstance(child, ast.FunctionDef | ast.ClassDef): + continue + if child.name == name: + if not rest: + return child + return get(*rest, node=child) + raise NameError + + return get + + +def test_create_parameters_google(get): + func = get("function_with_pep484_type_annotations") + x = list(create_parameters(func)) + assert x[0].name == "param1" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "int" + assert x[1].name == "param2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "str" + + func = get("module_level_function") + x = list(create_parameters(func)) + assert x[0].name == "param1" + assert x[0].type.expr is None + assert x[0].default is None + assert x[0].kind is Parameter.POSITIONAL_OR_KEYWORD + assert x[1].name == "param2" + assert x[1].type.expr is None + assert isinstance(x[1].default, ast.Constant) + assert x[1].default.value is None + assert x[1].kind is Parameter.POSITIONAL_OR_KEYWORD + assert x[2].name == "args" + assert x[2].type.expr is None + assert x[2].default is None + assert x[2].kind is Parameter.VAR_POSITIONAL + assert x[3].name == "kwargs" + assert x[3].type.expr is None + assert x[3].default is None + assert x[3].kind is Parameter.VAR_KEYWORD + + +def test_create_raises(get): + func = get("module_level_function") + x = next(create_raises(func)) + assert x.name == "ValueError" + assert x.text.str is None + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "ValueError" + + +def test_create_returns(get): + func = get("function_with_pep484_type_annotations") + x = next(create_returns(func)) + assert x.name == "" + assert isinstance(x.type.expr, ast.Name) + assert x.type.expr.id == "bool" + + +def test_create_attributes(google, get): + x = list(create_attributes(google)) + assert x[0].name == "module_level_variable1" + assert x[0].type.expr is None + assert x[0].text.str is None + assert isinstance(x[0].default, ast.Constant) + assert x[0].default.value == 12345 + assert x[1].name == "module_level_variable2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "int" + assert x[1].text.str + assert x[1].text.str.startswith("Module level") + assert x[1].text.str.endswith("by a colon.") + assert isinstance(x[1].default, ast.Constant) + assert x[1].default.value == 98765 + cls = get("ExamplePEP526Class") + x = list(create_attributes(cls)) + assert x[0].name == "attr1" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "str" + assert x[1].name == "attr2" + assert isinstance(x[1].type.expr, ast.Name) + assert x[1].type.expr.id == "int" + + +def test_create_attributes_from_property(get): + cls = get("ExampleClass") + x = list(create_attributes(cls)) + assert x[0].name == "readonly_property" + assert isinstance(x[0].type.expr, ast.Name) + assert x[0].type.expr.id == "str" + assert x[0].text.str + assert x[0].text.str.startswith("Properties should") + assert x[1].name == "readwrite_property" + assert isinstance(x[1].type.expr, ast.Subscript) + assert x[1].text.str + assert x[1].text.str.startswith("Properties with") + + +def test_repr(): + e = Element("abc", None, Type(None), Text(None)) + assert repr(e) == "Element(abc)" + src = "import matplotlib.pyplot as plt" + node = ast.parse(src).body[0] + assert isinstance(node, ast.Import) + x = list(_create_imports(node)) + assert repr(x[0]) == "Import(plt)" + + +def test_create_bases(): + node = ast.parse("class A(B, C[D]): passs") + cls = node.body[0] + assert isinstance(cls, ast.ClassDef) + bases = create_bases(cls) + base = next(bases) + assert base.name == "B" + assert isinstance(base.type.expr, ast.Name) + assert base.type.expr.id == "B" + base = next(bases) + assert base.name == "C" + assert isinstance(base.type.expr, ast.Subscript) + assert isinstance(base.type.expr.slice, ast.Name) diff --git a/tests/test_importlib.py b/tests/test_importlib.py new file mode 100644 index 00000000..d2416bd7 --- /dev/null +++ b/tests/test_importlib.py @@ -0,0 +1,118 @@ +import ast + +import pytest + +import mkapi.ast +import mkapi.objects +from mkapi.objects import ( + Class, + Function, + Module, + get_module_path, + get_object, + load_module, + modules, + objects, +) + + +@pytest.fixture(scope="module") +def module(): + path = get_module_path("mkdocs.structure.files") + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +def test_not_found(): + assert load_module("xxx") is None + assert mkapi.objects.modules["xxx"] is None + assert load_module("markdown") + assert "markdown" in mkapi.objects.modules + + +def test_repr(): + module = load_module("mkapi") + assert repr(module) == "Module(mkapi)" + module = load_module("mkapi.objects") + assert repr(module) == "Module(mkapi.objects)" + obj = get_object("mkapi.objects.Object") + assert repr(obj) == "Class(Object)" + + +def test_load_module_source(): + module = load_module("mkdocs.structure.files") + assert module + assert module.source + assert "class File" in module.source + module = load_module("mkapi.plugins") + assert module + cls = module.get_class("MkAPIConfig") + assert cls + assert cls.module is module + src = cls.get_source() + assert src + assert src.startswith("class MkAPIConfig") + src = module.get_source() + assert src + assert "MkAPIPlugin" in src + + +def test_load_module_from_object(): + module = load_module("mkdocs.structure.files") + assert module + c = module.classes[1] + m = c.module + assert module is m + + +def test_fullname(google: Module): + c = google.get_class("ExampleClass") + assert isinstance(c, Class) + f = c.get_function("example_method") + assert isinstance(f, Function) + assert c.fullname == "examples.styles.example_google.ExampleClass" + name = "examples.styles.example_google.ExampleClass.example_method" + assert f.fullname == name + + +def test_cache(): + modules.clear() + objects.clear() + module = load_module("mkapi.objects") + c = get_object("mkapi.objects.Object") + f = get_object("mkapi.objects.Module.get_class") + assert isinstance(c, Class) + assert c.module is module + assert isinstance(f, Function) + assert f.module is module + c2 = get_object("mkapi.objects.Object") + f2 = get_object("mkapi.objects.Module.get_class") + assert c is c2 + assert f is f2 + m1 = load_module("mkdocs.structure.files") + m2 = load_module("mkdocs.structure.files") + assert m1 is m2 + modules.clear() + m3 = load_module("mkdocs.structure.files") + m4 = load_module("mkdocs.structure.files") + assert m2 is not m3 + assert m3 is m4 + + +def test_module_kind(): + module = load_module("mkapi") + assert module + assert module.kind == "package" + module = load_module("mkapi.objects") + assert module + assert module.kind == "module" + + +def test_get_fullname_with_attr(): + module = load_module("mkapi.plugins") + assert module + name = module.get_fullname("config_options.Type") + assert name == "mkdocs.config.config_options.Type" + assert not module.get_fullname("config_options.A") diff --git a/tests/test_objects.py b/tests/test_objects.py new file mode 100644 index 00000000..c65292d7 --- /dev/null +++ b/tests/test_objects.py @@ -0,0 +1,162 @@ +import ast +import inspect +import sys +from pathlib import Path + +import pytest + +from mkapi.ast import iter_child_nodes +from mkapi.elements import Return +from mkapi.objects import ( + Class, + Function, + create_class, + create_function, + create_module, + iter_elements, + iter_objects, + objects, +) +from mkapi.utils import get_by_name, get_module_path + + +def load_module_node(name): + path = get_module_path(name) + assert path + with path.open("r", encoding="utf-8") as f: + source = f.read() + return ast.parse(source) + + +@pytest.fixture(scope="module") +def google(): + path = str(Path(__file__).parent.parent) + if path not in sys.path: + sys.path.insert(0, str(path)) + return load_module_node("examples.styles.example_google") + + +@pytest.fixture(scope="module") +def get(google): + def get(name, *rest, node=google): + for child in iter_child_nodes(node): + if not isinstance(child, ast.FunctionDef | ast.ClassDef): + continue + if child.name == name: + if not rest: + return child + return get(*rest, node=child) + raise NameError + + return get + + +def test_create_function(get): + node = get("module_level_function") + assert isinstance(node, ast.FunctionDef) + func = create_function(node) + assert isinstance(func, Function) + assert func.name == "module_level_function" + assert func.qualname == "module_level_function" + assert func.fullname == "__mkapi__.module_level_function" + assert "__mkapi__.module_level_function" in objects + assert objects["__mkapi__.module_level_function"] is func + assert len(func.parameters) == 4 + assert func.get_parameter("param1") + assert func.get_parameter("param2") + assert func.get_parameter("args") + assert func.get_parameter("kwargs") + assert len(func.returns) == 0 + assert len(func.raises) == 1 + assert len(func.doc.sections) == 4 + assert repr(func) == "Function(module_level_function)" + + +def test_create_class(get): + node = get("ExampleClass") + assert isinstance(node, ast.ClassDef) + cls = create_class(node) + assert isinstance(cls, Class) + assert cls.name == "ExampleClass" + assert len(cls.parameters) == 0 + assert len(cls.raises) == 0 + assert len(cls.functions) == 6 + assert cls.get_function("__init__") + assert cls.get_function("example_method") + assert cls.get_function("__special__") + assert cls.get_function("__special_without_docstring__") + assert cls.get_function("_private") + assert cls.get_function("_private_without_docstring") + assert len(cls.attributes) == 2 + assert cls.get_attribute("readonly_property") + assert cls.get_attribute("readwrite_property") + func = cls.get_function("__init__") + assert isinstance(func, Function) + assert func.qualname == "ExampleClass.__init__" + assert func.fullname == "__mkapi__.ExampleClass.__init__" + assert repr(cls) == "Class(ExampleClass)" + + +def test_create_module(google): + module = create_module(google, "google") + assert module.name == "google" + assert len(module.functions) == 4 + assert len(module.classes) == 3 + cls = module.get_class("ExampleClass") + assert isinstance(cls, Class) + assert cls.fullname == "google.ExampleClass" + func = cls.get_function("example_method") + assert isinstance(func, Function) + assert func.fullname == "google.ExampleClass.example_method" + assert repr(module) == "Module(google)" + + +def test_relative_import(): + """# test module + from .c import d + from ..e import f + """ + src = inspect.getdoc(test_relative_import) + assert src + node = ast.parse(src) + module = create_module(node, "x.y.z") + i = module.get_import("d") + assert i + assert i.fullname == "x.y.z.c.d" + i = module.get_import("f") + assert i + assert i.fullname == "x.y.e.f" + + +def test_iter(): + """# test module + m: str + n = 1 + class A(D): + a: int + def f(x: int, y: str) -> bool: + class B(E): + c: list + raise ValueError + """ + src = inspect.getdoc(test_iter) + assert src + node = ast.parse(src) + module = create_module(node, "x") + cls = module.get_class("A") + assert cls + func = cls.get_function("f") + assert func + cls = func.get_class("B") + assert cls + assert cls.fullname == "x.A.f.B" + + objs = iter_objects(module) + assert next(objs).name == "A" + assert next(objs).name == "f" + assert next(objs).name == "B" + elms = list(iter_elements(module)) + for x in "mnaxyc": + assert get_by_name(elms, x) + assert get_by_name(elms, "ValueError") + assert any(isinstance(x, Return) for x in elms) From 533cdee9cbb9edf61c9727816d33198349bfc5b6 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 14 Jan 2024 09:05:40 +0900 Subject: [PATCH 092/148] items --- src/mkapi/docstrings.py | 102 +++---- src/mkapi/importlib.py | 320 ++++------------------ src/mkapi/{elements.py => items.py} | 139 +++++++--- src/mkapi/objects.py | 41 +-- tests/docstrings/test_google.py | 41 +-- tests/docstrings/test_merge.py | 3 +- tests/docstrings/test_numpy.py | 41 +-- tests/{test_elements.py => test_items.py} | 64 ++--- tests/test_objects.py | 25 +- 9 files changed, 290 insertions(+), 486 deletions(-) rename src/mkapi/{elements.py => items.py} (53%) rename tests/{test_elements.py => test_items.py} (84%) diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 16462cc5..c1f95aed 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -6,7 +6,16 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from mkapi.elements import Text, Type +from mkapi.items import ( + Item, + Section, + Text, + Type, + create_attributes, + create_parameters, + create_raises, + create_returns, +) from mkapi.utils import ( add_admonition, add_fence, @@ -22,18 +31,6 @@ type Style = Literal["google", "numpy"] -@dataclass -class Item: - """Item class for section items.""" - - name: str - type: Type # noqa: A003 - text: Text - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name}:{self.type})" - - SPLIT_ITEM_PATTERN = re.compile(r"\n\S") SPLIT_NAME_TYPE_TEXT_PATTERN = re.compile(r"^\s*(\S+?)\s*\((.+?)\)\s*:\s*(.*)$") @@ -79,31 +76,14 @@ def split_item(item: str, style: Style) -> tuple[str, str, str]: return _split_item_numpy(lines) -def iter_items(section: str, style: Style, section_name: str) -> Iterator[Item]: - """Yield [Item] instances. - - If the section name is 'Raises', the type is set by its name. - """ +def iter_items(section: str, style: Style) -> Iterator[tuple[str, Type, Text]]: + """Yield tuples of (name, type, text).""" for item in _iter_items(section): name, type_, text = split_item(item, style) name = name.replace("*", "") # *args -> args, **kwargs -> kwargs - if section_name == "Raises": - type_ = name - yield Item(name, Type(ast.Constant(type_)), Text(text)) - - -@dataclass -class Section(Item): - """Section class of docstring.""" - - items: list[Item] - - def __iter__(self) -> Iterator[Item]: - return iter(self.items) - - def get(self, name: str) -> Item | None: - """Return an [Item] instance by name.""" - return get_by_name(self.items, name) + # if section_name == "Raises": + # type_ = name + yield name, Type(ast.Constant(type_)), Text(text) SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { @@ -206,47 +186,45 @@ def _iter_sections(doc: str, style: Style) -> Iterator[tuple[str, str]]: yield "", prev_text -def split_without_name(text: str, style: Style) -> tuple[str, str]: - """Return a tuple of (type, text) for Returns or Yields section.""" - lines = text.split("\n") - if style == "google" and ":" in lines[0]: - type_, text_ = lines[0].split(":", maxsplit=1) - return type_.strip(), "\n".join([text_.strip(), *lines[1:]]) - if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): - return lines[0], join_without_first_indent(lines[1:]) - return "", text +def _create_section(name: str, text: str, style: Style) -> Section: + if name == "Parameters": + return create_parameters(iter_items(text, style)) + if name == "Attributes": + return create_attributes(iter_items(text, style)) + if name == "Raises": + return create_raises(iter_items(text, style)) + if name in ["Returns", "Yields"]: + return create_returns(name, text, style) + raise NotImplementedError def iter_sections(doc: str, style: Style) -> Iterator[Section]: """Yield [Section] instances by splitting a docstring.""" + names = ["Parameters", "Attributes", "Raises", "Returns", "Yields"] for name, text in _iter_sections(doc, style): - type_, text_, items = Type(None), Text(None), [] - if name in ["Parameters", "Attributes", "Raises"]: - items = list(iter_items(text, style, name)) - elif name in ["Returns", "Yields"]: - type_, text_ = split_without_name(text, style) - type_ = Type(ast.Constant(type_) if type_ else None) - text_ = Text(text_ or None) - items = [Item("", type_, text_)] + if name in names: + yield _create_section(name, text, style) elif name in ["Note", "Notes", "Warning", "Warnings"]: - text_ = Text(add_admonition(name, text)) + text_ = add_admonition(name, text) + yield Section(name, Type(None), Text(text_), []) else: - text_ = Text(text) - yield Section(name, type_, text_, items) + yield Section(name, Type(None), Text(text), []) @dataclass -class Docstring: +class Docstring(Item): """Docstring class.""" - text: Text sections: list[Section] def __repr__(self) -> str: return f"{self.__class__.__name__}(num_sections={len(self.sections)})" - def __iter__(self) -> Iterator[Section]: - return iter(self.sections) + def __iter__(self) -> Iterator[Item]: + yield self + for section in self.sections: + yield section + yield from section.items def get(self, name: str) -> Section | None: """Return a [Section] by name.""" @@ -256,7 +234,7 @@ def get(self, name: str) -> Section | None: def parse(doc: str | None, style: Style | None = None) -> Docstring: """Return a [Docstring] instance.""" if not doc: - return Docstring(Text(None), []) + return Docstring("", Type(None), Text(None), []) doc = add_fence(doc) style = style or get_style(doc) sections = list(iter_sections(doc, style)) @@ -269,7 +247,7 @@ def parse(doc: str | None, style: Style | None = None) -> Docstring: text = Text(text) else: text = Text(None) - return Docstring(text, sections) + return Docstring("", Type(None), text, sections) def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: @@ -329,4 +307,4 @@ def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: sections.append(section) sections.extend(s for s in b.sections if not s.name) text = a.text or b.text - return Docstring(text, sections) + return Docstring("", Type(None), text, sections) diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 9685cf2c..66aa3a9a 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -1,254 +1,42 @@ -# """Object module.""" -# from __future__ import annotations - -# import ast -# import re -# from dataclasses import dataclass, field -# from typing import TYPE_CHECKING, ClassVar - -# import mkapi.ast -# import mkapi.dataclasses -# from mkapi import docstrings -# from mkapi.elements import ( -# Text, -# Type, -# create_attributes, -# create_imports, -# create_parameters, -# create_raises, -# create_returns, -# ) -# from mkapi.utils import ( -# del_by_name, -# get_by_name, -# get_module_path, -# iter_parent_modulenames, -# unique_names, -# ) - -# if TYPE_CHECKING: -# from collections.abc import Iterator -# from typing import Self - -# from mkapi.docstrings import Docstring, Item, Section -# from mkapi.elements import Attribute, Import, Parameter, Raise, Return - - -# @dataclass -# class Object: -# """Object class for class or function.""" - -# node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef -# name: str -# module: Module = field(init=False) -# doc: Docstring -# parameters: list[Parameter] -# raises: list[Raise] -# qualname: str = field(init=False) -# fullname: str = field(init=False) - -# def __post_init__(self) -> None: -# if not Module.current: -# raise NotImplementedError -# self.module = Module.current -# qualname = Class.classnames[-1] -# self.qualname = f"{qualname}.{self.name}" if qualname else self.name -# self.fullname = f"{self.module.name}.{self.qualname}" -# objects[self.fullname] = self # type:ignore - -# def __repr__(self) -> str: -# return f"{self.__class__.__name__}({self.name})" - -# def get_source(self, maxline: int | None = None) -> str | None: -# """Return the source code segment.""" -# if (module := self.module) and (source := module.source): -# start, stop = self.node.lineno - 1, self.node.end_lineno -# return "\n".join(source.split("\n")[start:stop][:maxline]) -# return None - -# def get_parameter(self, name: str) -> Parameter | None: -# """Return a [Parameter] instance by the name.""" -# return get_by_name(self.parameters, name) - -# def get_raise(self, name: str) -> Raise | None: -# """Return a [Raise] instance by the name.""" -# return get_by_name(self.raises, name) - -# @property -# def id(self) -> str: # noqa: A003, D102 -# return self.fullname - - -# objects: dict[str, Class | Function | Module | None] = {} - - -# @dataclass(repr=False) -# class Function(Object): -# """Function class.""" - -# node: ast.FunctionDef | ast.AsyncFunctionDef -# returns: list[Return] - - -# def create_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Function: -# """Return a [Function] instance.""" -# doc = docstrings.parse(ast.get_docstring(node)) -# parameters = list(create_parameters(node)) -# raises = list(create_raises(node)) -# returns = list(create_returns(node)) -# return Function(node, node.name, doc, parameters, raises, returns) - - -# @dataclass(repr=False) -# class Class(Object): -# """Class class.""" - -# node: ast.ClassDef -# attributes: list[Attribute] -# classes: list[Class] = field(default_factory=list, init=False) -# functions: list[Function] = field(default_factory=list, init=False) -# bases: list[Class] = field(default_factory=list, init=False) -# classnames: ClassVar[list[str | None]] = [None] - -# def get_attribute(self, name: str) -> Attribute | None: -# """Return an [Attribute] instance by the name.""" -# return get_by_name(self.attributes, name) - -# def get_class(self, name: str) -> Class | None: -# """Return a [Class] instance by the name.""" -# return get_by_name(self.classes, name) - -# def get_function(self, name: str) -> Function | None: -# """Return a [Function] instance by the name.""" -# return get_by_name(self.functions, name) - -# def iter_bases(self) -> Iterator[Class]: -# """Yield base classes including self.""" -# for base in self.bases: -# yield from base.iter_bases() -# yield self - - -# def create_class(node: ast.ClassDef) -> Class: -# """Return a [Class] instance.""" -# name = node.name -# doc = docstrings.parse(ast.get_docstring(node)) -# attributes = list(create_attributes(node)) -# cls = Class(node, name, doc, [], [], attributes) -# qualname = f"{Class.classnames[-1]}.{name}" if Class.classnames[-1] else name -# Class.classnames.append(qualname) -# for child in mkapi.ast.iter_child_nodes(node): -# if isinstance(child, ast.ClassDef): -# cls.classes.append(create_class(child)) -# elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): -# func = create_function(child) -# if not get_by_name(cls.attributes, func.name): # for property -# cls.functions.append(func) -# Class.classnames.pop() -# return cls - - -# @dataclass -# class Module: -# """Module class.""" - -# node: ast.Module -# name: str -# doc: Docstring -# imports: list[Import] -# attributes: list[Attribute] -# classes: list[Class] = field(default_factory=list, init=False) -# functions: list[Function] = field(default_factory=list, init=False) -# source: str | None = None -# kind: str | None = None -# current: ClassVar[Self | None] = None - -# def __repr__(self) -> str: -# return f"{self.__class__.__name__}({self.name})" - -# def get_import(self, name: str) -> Import | None: -# """Return an [Import] instance by the name.""" -# return get_by_name(self.imports, name) - -# def get_attribute(self, name: str) -> Attribute | None: -# """Return an [Attribute] instance by the name.""" -# return get_by_name(self.attributes, name) - -# def get_class(self, name: str) -> Class | None: -# """Return an [Class] instance by the name.""" -# return get_by_name(self.classes, name) - -# def get_function(self, name: str) -> Function | None: -# """Return an [Function] instance by the name.""" -# return get_by_name(self.functions, name) - -# def get_fullname(self, name: str | None = None) -> str | None: -# """Return the fullname of the module.""" -# if not name: -# return self.name -# if obj := self.get_member(name): -# return obj.fullname -# if "." in name: -# name, attr = name.rsplit(".", maxsplit=1) -# if import_ := self.get_import(name): # noqa: SIM102 -# if module := load_module(import_.fullname): -# return module.get_fullname(attr) -# return None - -# def get_source(self, maxline: int | None = None) -> str | None: -# """Return the source of the module.""" -# if not self.source: -# return None -# return "\n".join(self.source.split("\n")[:maxline]) - -# def get_member(self, name: str) -> Import | Class | Function | None: -# """Return a member instance by the name.""" -# if obj := self.get_import(name): -# return obj -# if obj := self.get_class(name): -# return obj -# if obj := self.get_function(name): -# return obj -# return None - -# # def set_markdown(self) -> None: -# # """Set markdown with link form.""" -# # for type_ in self.types: -# # type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) -# # for text in self.texts: -# # text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) - -# # def _get_link_type(self, name: str) -> str: -# # if fullname := self.get_fullname(name): -# # return get_link(name, fullname) -# # return name - -# # def _get_link_text(self, match: re.Match) -> str: -# # name = match.group(1) -# # if fullname := self.get_fullname(name): -# # return get_link(name, fullname) -# # return match.group() - - -# def create_module_from_node(node: ast.Module, name: str = "__mkapi__") -> Module: -# """Return a [Module] instance from an [ast.Module] node.""" -# doc = docstrings.parse(ast.get_docstring(node)) -# imports = [] -# for import_ in create_imports(node): -# if import_.level: -# prefix = ".".join(name.split(".")[: import_.level]) -# import_.fullname = f"{prefix}.{import_.fullname}" -# imports.append(import_) -# attributes = list(create_attributes(node)) -# module = Module(node, name, doc, imports, attributes, None, None) -# Module.current = module -# for child in mkapi.ast.iter_child_nodes(node): -# if isinstance(child, ast.ClassDef): -# module.classes.append(create_class(child)) -# elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): -# module.functions.append(create_function(child)) -# Module.current = None -# return module +"""importlib module.""" +from __future__ import annotations + +import ast +import re +from typing import TYPE_CHECKING + +import mkapi.dataclasses +from mkapi import docstrings + +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Self + + from mkapi.docstrings import Docstring, Item, Section + from mkapi.items import Attribute, Import, Parameter, Raise, Return + + +def load_module(name: str) -> Module | None: + """Return a [Module] instance by the name.""" + if name in modules: + return modules[name] + if not (path := get_module_path(name)): + modules[name] = None + return None + with path.open("r", encoding="utf-8") as f: + source = f.read() + module = load_module_from_source(source, name) + module.kind = "package" if path.stem == "__init__" else "module" + modules[name] = module + return module + + +def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: + """Return a [Module] instance from a source string.""" + node = ast.parse(source) + module = create_module_from_node(node, name) + module.source = source + return module # def get_object(fullname: str) -> Module | Class | Function | None: @@ -273,29 +61,6 @@ # modules: dict[str, Module | None] = {} -# def load_module(name: str) -> Module | None: -# """Return a [Module] instance by the name.""" -# if name in modules: -# return modules[name] -# if not (path := get_module_path(name)): -# modules[name] = None -# return None -# with path.open("r", encoding="utf-8") as f: -# source = f.read() -# module = load_module_from_source(source, name) -# module.kind = "package" if path.stem == "__init__" else "module" -# modules[name] = module -# return module - - -# def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: -# """Return a [Module] instance from a source string.""" -# node = ast.parse(source) -# module = create_module_from_node(node, name) -# module.source = source -# return module - - # # def _postprocess(obj: Module | Class) -> None: # # _merge_docstring(obj) # # for func in obj.functions: @@ -419,3 +184,10 @@ # # parameter.type = attr.type # # parameter.module = attr.module # # cls.parameters.append(parameter) + +# def get_globals(module: Module) -> dict[str, Class | Function | Attribute | Import]: +# members = module.classes + module.functions + module.imports +# globals_ = {member.fullname: member for member in members} +# for attribute in module.attributes: +# globals_[f"{module.name}.{attribute.name}"] = attribute # type: ignore +# return globals_ diff --git a/src/mkapi/elements.py b/src/mkapi/items.py similarity index 53% rename from src/mkapi/elements.py rename to src/mkapi/items.py index d60037c2..ce666729 100644 --- a/src/mkapi/elements.py +++ b/src/mkapi/items.py @@ -8,10 +8,10 @@ import mkapi.ast import mkapi.dataclasses from mkapi.ast import is_property -from mkapi.utils import iter_parent_modulenames +from mkapi.utils import get_by_name, iter_parent_modulenames, join_without_first_indent if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator from inspect import _ParameterKind @@ -32,11 +32,10 @@ class Text: @dataclass -class Element: +class Item: """Element class.""" name: str - node: ast.AST | None type: Type # noqa: A003 text: Text @@ -45,75 +44,67 @@ def __repr__(self) -> str: @dataclass(repr=False) -class Parameter(Element): +class Parameter(Item): """Parameter class for [Class] or [Function].""" - node: ast.arg | None default: ast.expr | None kind: _ParameterKind | None -def create_parameters( +def iter_parameters( node: ast.FunctionDef | ast.AsyncFunctionDef, ) -> Iterator[Parameter]: """Yield parameters from the function node.""" for arg, kind, default in mkapi.ast.iter_parameters(node): type_ = Type(arg.annotation) - yield Parameter(arg.arg, arg, type_, Text(None), default, kind) + yield Parameter(arg.arg, type_, Text(None), default, kind) @dataclass(repr=False) -class Raise(Element): +class Raise(Item): """Raise class for [Class] or [Function].""" - node: ast.Raise | None - -def create_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: +def iter_raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Raise]: """Yield [Raise] instances.""" for ret in mkapi.ast.iter_raises(node): if type_ := ret.exc: if isinstance(type_, ast.Call): type_ = type_.func name = ast.unparse(type_) - yield Raise(name, ret, Type(type_), Text(None)) + yield Raise(name, Type(type_), Text(None)) @dataclass(repr=False) -class Return(Element): +class Return(Item): """Return class for [Class] or [Function].""" - node: ast.expr | None - -def create_returns(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Return]: +def iter_returns(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[Return]: """Return a [Return] instance.""" if node.returns: - yield Return("", node.returns, Type(node.returns), Text(None)) + yield Return("", Type(node.returns), Text(None)) @dataclass(repr=False) -class Base(Element): +class Base(Item): """Base class for [Class].""" - node: ast.expr | None - -def create_bases(node: ast.ClassDef) -> Iterator[Base]: +def iter_bases(node: ast.ClassDef) -> Iterator[Base]: """Yield [Raise] instances.""" for base in node.bases: if isinstance(base, ast.Subscript): name = ast.unparse(base.value) else: name = ast.unparse(base) - yield Base(name, base, Type(base), Text(None)) + yield Base(name, Type(base), Text(None)) @dataclass(repr=False) class Import: """Import class for [Module].""" - node: ast.Import | ast.ImportFrom name: str fullname: str from_: str | None @@ -123,38 +114,37 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" -def _create_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: +def _iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: """Yield [Import] instances.""" for alias in node.names: if isinstance(node, ast.Import): if alias.asname: - yield Import(node, alias.asname, alias.name, None, 0) + yield Import(alias.asname, alias.name, None, 0) else: for fullname in iter_parent_modulenames(alias.name): - yield Import(node, fullname, fullname, None, 0) + yield Import(fullname, fullname, None, 0) else: name = alias.asname or alias.name from_ = f"{node.module}" fullname = f"{from_}.{alias.name}" - yield Import(node, name, fullname, from_, node.level) + yield Import(name, fullname, from_, node.level) -def create_imports(node: ast.Module) -> Iterator[Import]: +def iter_imports(node: ast.Module) -> Iterator[Import]: """Yield [Import] instances.""" for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.Import | ast.ImportFrom): - yield from _create_imports(child) + yield from _iter_imports(child) @dataclass(repr=False) -class Attribute(Element): +class Attribute(Item): """Atrribute class for [Module] or [Class].""" - node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None default: ast.expr | None -def create_attributes(node: ast.ClassDef | ast.Module) -> Iterator[Attribute]: +def iter_attributes(node: ast.ClassDef | ast.Module) -> Iterator[Attribute]: """Yield [Attribute] instances.""" for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.AnnAssign | ast.Assign | ast.TypeAlias): @@ -171,20 +161,20 @@ def create_attribute(node: ast.AnnAssign | ast.Assign | ast.TypeAlias) -> Attrib type_ = mkapi.ast.get_assign_type(node) type_, text = _attribute_type_text(type_, node.__doc__) default = None if isinstance(node, ast.TypeAlias) else node.value - return Attribute(name, node, type_, text, default) + return Attribute(name, type_, text, default) def create_attribute_from_property(node: ast.FunctionDef) -> Attribute: """Return an [Attribute] instance from a property.""" text = ast.get_docstring(node) type_, text = _attribute_type_text(node.returns, text) - return Attribute(node.name, node, type_, text, None) + return Attribute(node.name, type_, text, None) def _attribute_type_text(type_: ast.expr | None, text: str | None) -> tuple[Type, Text]: if not text: return Type(type_), Text(None) - type_doc, text = _split_without_name(text) + type_doc, text = split_without_name(text, "google") if not type_ and type_doc: # ex. 'list(str)' -> 'list[str]' for ast.expr type_doc = type_doc.replace("(", "[").replace(")", "]") @@ -192,10 +182,83 @@ def _attribute_type_text(type_: ast.expr | None, text: str | None) -> tuple[Type return Type(type_), Text(text) -def _split_without_name(text: str) -> tuple[str, str]: +def split_without_name(text: str, style: str) -> tuple[str, str]: """Return a tuple of (type, text) for Returns or Yields section.""" lines = text.split("\n") - if ":" in lines[0]: + if style == "google" and ":" in lines[0]: type_, text_ = lines[0].split(":", maxsplit=1) return type_.strip(), "\n".join([text_.strip(), *lines[1:]]) + if style == "numpy" and len(lines) > 1 and lines[1].startswith(" "): + return lines[0], join_without_first_indent(lines[1:]) return "", text + + +@dataclass(repr=False) +class Section(Item): + """Section class of docstring.""" + + items: list[Item] + + def __iter__(self) -> Iterator[Item]: + return iter(self.items) + + def get(self, name: str) -> Item | None: + """Return an [Item] instance by name.""" + return get_by_name(self.items, name) + + +@dataclass(repr=False) +class Parameters(Section): + """Parameters section.""" + + items: list[Parameter] + + +def create_parameters(items: Iterable[tuple[str, Type, Text]]) -> Parameters: + """Return a parameters section.""" + parameters = [Parameter(*args, None, None) for args in items] + return Parameters("Parameters", Type(None), Text(None), parameters) + + +@dataclass(repr=False) +class Attributes(Section): + """Attributes section.""" + + items: list[Attribute] + + +def create_attributes(items: Iterable[tuple[str, Type, Text]]) -> Attributes: + """Return an attributes section.""" + attributes = [Attribute(*args, None) for args in items] + return Attributes("Attributes", Type(None), Text(None), attributes) + + +@dataclass(repr=False) +class Raises(Section): + """Raises section.""" + + items: list[Raise] + + +def create_raises(items: Iterable[tuple[str, Type, Text]]) -> Raises: + """Return a raises section.""" + raises = [Raise(*args) for args in items] + for raise_ in raises: + raise_.type.expr = ast.Constant(raise_.name) + return Raises("Raises", Type(None), Text(None), raises) + + +@dataclass(repr=False) +class Returns(Section): + """Returns section.""" + + items: list[Return] + + +def create_returns(name: str, text: str, style: str) -> Returns: + """Return a returns section.""" + type_, text_ = split_without_name(text, style) + type_ = Type(ast.Constant(type_) if type_ else None) + text_ = Text(text_ or None) + returns = [Return("", type_, text_)] + return Returns(name, Type(None), Text(None), returns) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 364bf093..543fefd7 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -9,21 +9,23 @@ import mkapi.dataclasses from mkapi import docstrings from mkapi.docstrings import Docstring -from mkapi.elements import ( +from mkapi.items import ( + Item, Text, - create_attributes, - create_bases, - create_imports, - create_parameters, - create_raises, - create_returns, + Type, + iter_attributes, + iter_bases, + iter_imports, + iter_parameters, + iter_raises, + iter_returns, ) from mkapi.utils import get_by_name if TYPE_CHECKING: from collections.abc import Iterator - from mkapi.elements import Attribute, Base, Import, Parameter, Raise, Return + from mkapi.items import Attribute, Base, Import, Parameter, Raise, Return @dataclass @@ -96,9 +98,9 @@ def create_function( """Return a [Function] instance.""" module = module or _create_empty_module() doc = docstrings.parse(ast.get_docstring(node)) - parameters = list(create_parameters(node)) - raises = list(create_raises(node)) - returns = list(create_returns(node)) + parameters = list(iter_parameters(node)) + raises = list(iter_raises(node)) + returns = list(iter_returns(node)) args = ([], [], parameters, raises, returns) func = Function(node, node.name, module, parent, doc, *args) for child in mkapi.ast.iter_child_nodes(node): @@ -131,8 +133,8 @@ def create_class( name = node.name module = module or _create_empty_module() doc = docstrings.parse(ast.get_docstring(node)) - attributes = list(create_attributes(node)) - bases = list(create_bases(node)) + attributes = list(iter_attributes(node)) + bases = list(iter_bases(node)) args = ([], [], [], [], attributes, bases) cls = Class(node, name, module, parent, doc, *args) for child in mkapi.ast.iter_child_nodes(node): @@ -198,13 +200,13 @@ def create_module(node: ast.Module, name: str = "__mkapi__") -> Module: """Return a [Module] instance from an [ast.Module] node.""" doc = docstrings.parse(ast.get_docstring(node)) imports = [] - for import_ in create_imports(node): + for import_ in iter_imports(node): if import_.level: names = name.split(".") prefix = ".".join(name.split(".")[: len(names) - import_.level + 1]) import_.fullname = f"{prefix}.{import_.fullname}" imports.append(import_) - attributes = list(create_attributes(node)) + attributes = list(iter_attributes(node)) module = Module(node, name, doc, imports, attributes, None, None) for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.ClassDef): @@ -216,7 +218,7 @@ def create_module(node: ast.Module, name: str = "__mkapi__") -> Module: def _create_empty_module() -> Module: name = "__mkapi__" - doc = Docstring(Text(None), []) + doc = Docstring("", Type(None), Text(None), []) return Module(None, name, doc, [], [], None, None) @@ -230,13 +232,11 @@ def iter_objects(obj: Module | Class | Function) -> Iterator[Class | Function]: yield from iter_objects(func) -def iter_elements( - obj: Module | Class | Function, -) -> Iterator[Attribute | Parameter | Raise | Return | Base]: +def iter_items(obj: Module | Class | Function) -> Iterator[Item]: """Yield [Element] instances.""" for obj_ in iter_objects(obj): if obj_ is not obj: - yield from iter_elements(obj_) + yield from iter_items(obj_) if isinstance(obj, Function | Class): yield from obj.parameters yield from obj.raises @@ -246,3 +246,4 @@ def iter_elements( yield from obj.attributes if isinstance(obj, Class): yield from obj.bases + yield from obj.doc diff --git a/tests/docstrings/test_google.py b/tests/docstrings/test_google.py index 871a0ad0..bbe112b0 100644 --- a/tests/docstrings/test_google.py +++ b/tests/docstrings/test_google.py @@ -7,7 +7,6 @@ parse, split_item, split_section, - split_without_name, ) @@ -97,31 +96,21 @@ def test_iter_items_class(google, get, get_node): doc = get(google, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[1][1] - x = list(iter_items(section, "google", "A")) - assert x[0].name == "attr1" - assert x[0].type.expr.value == "str" # type: ignore - assert x[0].text.str == "Description of `attr1`." - assert x[1].name == "attr2" - assert x[1].type.expr.value == ":obj:`int`, optional" # type: ignore - assert x[1].text.str == "Description of `attr2`." + x = list(iter_items(section, "google")) + assert x[0][0] == "attr1" + assert x[0][1].expr.value == "str" # type: ignore + assert x[0][2].str == "Description of `attr1`." + assert x[1][0] == "attr2" + assert x[1][1].expr.value == ":obj:`int`, optional" # type: ignore + assert x[1][2].str == "Description of `attr2`." doc = get(get_node(google, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "google"))[2][1] - x = list(iter_items(section, "google", "A")) - assert x[0].name == "param1" - assert x[0].type.expr.value == "str" # type: ignore - assert x[0].text.str == "Description of `param1`." - assert x[1].text.str == "Description of `param2`. Multiple\nlines are supported." - - -def test_split_without_name(google, get): - doc = get(google, "module_level_function") - assert isinstance(doc, str) - section = list(_iter_sections(doc, "google"))[2][1] - x = split_without_name(section, "google") - assert x[0] == "bool" - assert x[1].startswith("True if") - assert x[1].endswith(" }") + x = list(iter_items(section, "google")) + assert x[0][0] == "param1" + assert x[0][1].expr.value == "str" # type: ignore + assert x[0][2].str == "Description of `param1`." + assert x[1][2].str == "Description of `param2`. Multiple\nlines are supported." def test_iter_items_raises(google, get): @@ -129,10 +118,10 @@ def test_iter_items_raises(google, get): assert isinstance(doc, str) name, section = list(_iter_sections(doc, "google"))[3] assert name == "Raises" - items = list(iter_items(section, "google", name)) + items = list(iter_items(section, "google")) assert len(items) == 2 - assert items[0].type.expr.value == items[0].name == "AttributeError" # type: ignore - assert items[1].type.expr.value == items[1].name == "ValueError" # type: ignore + assert items[0][0] == "AttributeError" + assert items[1][0] == "ValueError" def test_parse(google): diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index 8f0f8006..d9284705 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -27,5 +27,6 @@ def test_merge(google, get, get_node): b = parse(get(get_node(google, "ExampleClass"), "__init__")) doc = merge(a, b) assert doc - assert [s.name for s in doc] == ["", "Attributes", "Note", "Parameters", ""] + names = ["", "Attributes", "Note", "Parameters", ""] + assert [s.name for s in doc.sections] == names doc.sections[-1].text.str.endswith("with it.") # type: ignore diff --git a/tests/docstrings/test_numpy.py b/tests/docstrings/test_numpy.py index 45edcb4e..7fc57017 100644 --- a/tests/docstrings/test_numpy.py +++ b/tests/docstrings/test_numpy.py @@ -6,7 +6,6 @@ iter_items, split_item, split_section, - split_without_name, ) @@ -93,31 +92,21 @@ def test_iter_items_class(numpy, get, get_node): doc = get(numpy, "ExampleClass") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[1][1] - x = list(iter_items(section, "numpy", "A")) - assert x[0].name == "attr1" - assert x[0].type.expr.value == "str" # type: ignore - assert x[0].text.str == "Description of `attr1`." - assert x[1].name == "attr2" - assert x[1].type.expr.value == ":obj:`int`, optional" # type: ignore - assert x[1].text.str == "Description of `attr2`." + x = list(iter_items(section, "numpy")) + assert x[0][0] == "attr1" + assert x[0][1].expr.value == "str" # type: ignore + assert x[0][2].str == "Description of `attr1`." + assert x[1][0] == "attr2" + assert x[1][1].expr.value == ":obj:`int`, optional" # type: ignore + assert x[1][2].str == "Description of `attr2`." doc = get(get_node(numpy, "ExampleClass"), "__init__") assert isinstance(doc, str) section = list(_iter_sections(doc, "numpy"))[2][1] - x = list(iter_items(section, "numpy", "A")) - assert x[0].name == "param1" - assert x[0].type.expr.value == "str" # type: ignore - assert x[0].text.str == "Description of `param1`." - assert x[1].text.str == "Description of `param2`. Multiple\nlines are supported." - - -def test_get_return(numpy, get): - doc = get(numpy, "module_level_function") - assert isinstance(doc, str) - section = list(_iter_sections(doc, "numpy"))[2][1] - x = split_without_name(section, "numpy") - assert x[0] == "bool" - assert x[1].startswith("True if") - assert x[1].endswith(" }") + x = list(iter_items(section, "numpy")) + assert x[0][0] == "param1" + assert x[0][1].expr.value == "str" # type: ignore + assert x[0][2].str == "Description of `param1`." + assert x[1][2].str == "Description of `param2`. Multiple\nlines are supported." def test_iter_items_raises(numpy, get): @@ -125,7 +114,7 @@ def test_iter_items_raises(numpy, get): assert isinstance(doc, str) name, section = list(_iter_sections(doc, "numpy"))[3] assert name == "Raises" - items = list(iter_items(section, "numpy", name)) + items = list(iter_items(section, "numpy")) assert len(items) == 2 - assert items[0].type.expr.value == items[0].name == "AttributeError" # type: ignore - assert items[1].type.expr.value == items[1].name == "ValueError" # type: ignore + assert items[0][0] == "AttributeError" # type: ignore + assert items[1][0] == "ValueError" # type: ignore diff --git a/tests/test_elements.py b/tests/test_items.py similarity index 84% rename from tests/test_elements.py rename to tests/test_items.py index d866fe2d..7e4c65e8 100644 --- a/tests/test_elements.py +++ b/tests/test_items.py @@ -6,50 +6,50 @@ import pytest from mkapi.ast import iter_child_nodes -from mkapi.elements import ( +from mkapi.items import ( Attribute, - Element, + Item, Text, Type, - _create_imports, - create_attributes, - create_bases, - create_parameters, - create_raises, - create_returns, + _iter_imports, + iter_attributes, + iter_bases, + iter_parameters, + iter_raises, + iter_returns, ) from mkapi.utils import get_module_path -def _get_args(source: str): +def _get_parameters(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.FunctionDef) - return list(create_parameters(node)) + return list(iter_parameters(node)) def test_create_parameters(): - args = _get_args("def f():\n pass") + args = _get_parameters("def f():\n pass") assert not args - args = _get_args("def f(x):\n pass") + args = _get_parameters("def f(x):\n pass") assert args[0].type.expr is None assert args[0].default is None assert args[0].kind is Parameter.POSITIONAL_OR_KEYWORD - x = _get_args("def f(x=1):\n pass")[0] + x = _get_parameters("def f(x=1):\n pass")[0] assert isinstance(x.default, ast.Constant) - x = _get_args("def f(x:str='s'):\n pass")[0] + x = _get_parameters("def f(x:str='s'):\n pass")[0] assert x.type.expr assert isinstance(x.type.expr, ast.Name) assert x.type.expr.id == "str" assert isinstance(x.default, ast.Constant) assert x.default.value == "s" - x = _get_args("def f(x:'X'='s'):\n pass")[0] + x = _get_parameters("def f(x:'X'='s'):\n pass")[0] assert x.type assert isinstance(x.type.expr, ast.Constant) assert x.type.expr.value == "X" def test_create_parameters_tuple(): - x = _get_args("def f(x:tuple[int]=(1,)):\n pass")[0] + x = _get_parameters("def f(x:tuple[int]=(1,)):\n pass")[0] assert x.type node = x.type.expr assert isinstance(node, ast.Subscript) @@ -63,7 +63,7 @@ def test_create_parameters_tuple(): def test_create_parameters_slice(): - x = _get_args("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] + x = _get_parameters("def f(x:tuple[int,str]=(1,'s')):\n pass")[0] assert x.type node = x.type.expr assert isinstance(node, ast.Subscript) @@ -78,7 +78,7 @@ def test_create_parameters_slice(): def _get_attributes(source: str): node = ast.parse(source).body[0] assert isinstance(node, ast.ClassDef) - return list(create_attributes(node)) + return list(iter_attributes(node)) def test_get_attributes(): @@ -127,21 +127,21 @@ def test_iter_import_nodes_alias(): src = "import matplotlib.pyplot" node = ast.parse(src).body[0] assert isinstance(node, ast.Import) - x = list(_create_imports(node)) + x = list(_iter_imports(node)) assert len(x) == 2 assert x[0].fullname == "matplotlib" assert x[1].fullname == "matplotlib.pyplot" src = "import matplotlib.pyplot as plt" node = ast.parse(src).body[0] assert isinstance(node, ast.Import) - x = list(_create_imports(node)) + x = list(_iter_imports(node)) assert len(x) == 1 assert x[0].fullname == "matplotlib.pyplot" assert x[0].name == "plt" src = "from matplotlib import pyplot as plt" node = ast.parse(src).body[0] assert isinstance(node, ast.ImportFrom) - x = list(_create_imports(node)) + x = list(_iter_imports(node)) assert len(x) == 1 assert x[0].fullname == "matplotlib.pyplot" assert x[0].name == "plt" @@ -180,7 +180,7 @@ def get(name, *rest, node=google): def test_create_parameters_google(get): func = get("function_with_pep484_type_annotations") - x = list(create_parameters(func)) + x = list(iter_parameters(func)) assert x[0].name == "param1" assert isinstance(x[0].type.expr, ast.Name) assert x[0].type.expr.id == "int" @@ -189,7 +189,7 @@ def test_create_parameters_google(get): assert x[1].type.expr.id == "str" func = get("module_level_function") - x = list(create_parameters(func)) + x = list(iter_parameters(func)) assert x[0].name == "param1" assert x[0].type.expr is None assert x[0].default is None @@ -211,7 +211,7 @@ def test_create_parameters_google(get): def test_create_raises(get): func = get("module_level_function") - x = next(create_raises(func)) + x = next(iter_raises(func)) assert x.name == "ValueError" assert x.text.str is None assert isinstance(x.type.expr, ast.Name) @@ -220,14 +220,14 @@ def test_create_raises(get): def test_create_returns(get): func = get("function_with_pep484_type_annotations") - x = next(create_returns(func)) + x = next(iter_returns(func)) assert x.name == "" assert isinstance(x.type.expr, ast.Name) assert x.type.expr.id == "bool" def test_create_attributes(google, get): - x = list(create_attributes(google)) + x = list(iter_attributes(google)) assert x[0].name == "module_level_variable1" assert x[0].type.expr is None assert x[0].text.str is None @@ -242,7 +242,7 @@ def test_create_attributes(google, get): assert isinstance(x[1].default, ast.Constant) assert x[1].default.value == 98765 cls = get("ExamplePEP526Class") - x = list(create_attributes(cls)) + x = list(iter_attributes(cls)) assert x[0].name == "attr1" assert isinstance(x[0].type.expr, ast.Name) assert x[0].type.expr.id == "str" @@ -253,7 +253,7 @@ def test_create_attributes(google, get): def test_create_attributes_from_property(get): cls = get("ExampleClass") - x = list(create_attributes(cls)) + x = list(iter_attributes(cls)) assert x[0].name == "readonly_property" assert isinstance(x[0].type.expr, ast.Name) assert x[0].type.expr.id == "str" @@ -266,12 +266,12 @@ def test_create_attributes_from_property(get): def test_repr(): - e = Element("abc", None, Type(None), Text(None)) - assert repr(e) == "Element(abc)" + e = Item("abc", Type(None), Text(None)) + assert repr(e) == "Item(abc)" src = "import matplotlib.pyplot as plt" node = ast.parse(src).body[0] assert isinstance(node, ast.Import) - x = list(_create_imports(node)) + x = list(_iter_imports(node)) assert repr(x[0]) == "Import(plt)" @@ -279,7 +279,7 @@ def test_create_bases(): node = ast.parse("class A(B, C[D]): passs") cls = node.body[0] assert isinstance(cls, ast.ClassDef) - bases = create_bases(cls) + bases = iter_bases(cls) base = next(bases) assert base.name == "B" assert isinstance(base.type.expr, ast.Name) diff --git a/tests/test_objects.py b/tests/test_objects.py index c65292d7..012c4b29 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -6,14 +6,15 @@ import pytest from mkapi.ast import iter_child_nodes -from mkapi.elements import Return +from mkapi.docstrings import Docstring +from mkapi.items import Item, Return, Section from mkapi.objects import ( Class, Function, create_class, create_function, create_module, - iter_elements, + iter_items, iter_objects, objects, ) @@ -129,12 +130,19 @@ def test_relative_import(): def test_iter(): - """# test module + """'''test module.''' m: str n = 1 + '''int: attribute n.''' class A(D): + '''class. + + Args: + a: attribute a. + ''' a: int def f(x: int, y: str) -> bool: + '''function.''' class B(E): c: list raise ValueError @@ -155,8 +163,11 @@ class B(E): assert next(objs).name == "A" assert next(objs).name == "f" assert next(objs).name == "B" - elms = list(iter_elements(module)) + items = list(iter_items(module)) for x in "mnaxyc": - assert get_by_name(elms, x) - assert get_by_name(elms, "ValueError") - assert any(isinstance(x, Return) for x in elms) + assert get_by_name(items, x) + assert get_by_name(items, "ValueError") + assert any(isinstance(x, Return) for x in items) + assert any(isinstance(x, Item) for x in items) + assert any(isinstance(x, Section) for x in items) + assert any(isinstance(x, Docstring) for x in items) From 8988c5a6d6bd90b75a42e3f2550c4fd90dc23071 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 14 Jan 2024 11:53:01 +0900 Subject: [PATCH 093/148] merge items --- src/mkapi/docstrings.py | 15 ++++- src/mkapi/items.py | 40 +++++++++++++- src/mkapi/objects.py | 120 +++++++++++++++++++++++++++++++++++----- src/mkapi/utils.py | 7 +++ tests/test_items.py | 26 +++++++++ tests/test_objects.py | 58 +++++++++++++++++-- 6 files changed, 240 insertions(+), 26 deletions(-) diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index c1f95aed..5ab7c388 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -81,9 +81,8 @@ def iter_items(section: str, style: Style) -> Iterator[tuple[str, Type, Text]]: for item in _iter_items(section): name, type_, text = split_item(item, style) name = name.replace("*", "") # *args -> args, **kwargs -> kwargs - # if section_name == "Raises": - # type_ = name - yield name, Type(ast.Constant(type_)), Text(text) + type_ = ast.Constant(type_) if type_ else None + yield name, Type(type_), Text(text) SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { @@ -226,6 +225,16 @@ def __iter__(self) -> Iterator[Item]: yield section yield from section.items + def iter_types(self) -> Iterator[Type]: # noqa: D102 + for item in self: + if item.type.expr: + yield item.type + + def iter_texts(self) -> Iterator[Text]: # noqa: D102 + for item in self: + if item.text.str: + yield item.text + def get(self, name: str) -> Section | None: """Return a [Section] by name.""" return get_by_name(self.sections, name) diff --git a/src/mkapi/items.py b/src/mkapi/items.py index ce666729..f54fda58 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -8,10 +8,15 @@ import mkapi.ast import mkapi.dataclasses from mkapi.ast import is_property -from mkapi.utils import get_by_name, iter_parent_modulenames, join_without_first_indent +from mkapi.utils import ( + get_by_name, + iter_parent_modulenames, + join_without_first_indent, + unique_names, +) if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Iterable, Iterator, Sequence from inspect import _ParameterKind @@ -199,6 +204,12 @@ class Section(Item): items: list[Item] + def __repr__(self) -> str: + if not self.items: + return f"{self.__class__.__name__}({self.name})" + args = ", ".join(item.name for item in self.items) + return f"{self.__class__.__name__}({args})" + def __iter__(self) -> Iterator[Item]: return iter(self.items) @@ -262,3 +273,28 @@ def create_returns(name: str, text: str, style: str) -> Returns: text_ = Text(text_ or None) returns = [Return("", type_, text_)] return Returns(name, Type(None), Text(None), returns) + + +@dataclass(repr=False) +class Bases(Section): + """Bases section.""" + + items: list[Base] + + +def iter_merged_items[T](items_ast: Sequence[T], items_doc: Sequence[T]) -> Iterator[T]: + """Yield merged [Item] instances. + + `items_ast` are overwritten in-place. + """ + for name in unique_names(items_ast, items_doc): + item_ast, item_doc = get_by_name(items_ast, name), get_by_name(items_doc, name) + if item_ast and not item_doc: + yield item_ast + elif not item_ast and item_doc: + yield item_doc + if isinstance(item_ast, Item) and isinstance(item_doc, Item): + item_ast.name = item_ast.name or item_doc.name + item_ast.type = item_ast.type if item_ast.type.expr else item_doc.type + item_ast.text = item_ast.text if item_ast.text.str else item_doc.text + yield item_ast diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 543fefd7..09deda9a 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -10,17 +10,26 @@ from mkapi import docstrings from mkapi.docstrings import Docstring from mkapi.items import ( + Attributes, + Bases, Item, + Parameters, + Raises, + Returns, Text, Type, + create_attributes, + create_parameters, + create_raises, iter_attributes, iter_bases, iter_imports, + iter_merged_items, iter_parameters, iter_raises, iter_returns, ) -from mkapi.utils import get_by_name +from mkapi.utils import get_by_name, get_by_type if TYPE_CHECKING: from collections.abc import Iterator @@ -50,6 +59,10 @@ def __post_init__(self) -> None: else: self.qualname = self.name self.fullname = f"{self.module.name}.{self.qualname}" + self.doc.name = self.fullname + expr = ast.parse(self.fullname).body[0] + if isinstance(expr, ast.Expr): + self.doc.type.expr = expr.value objects[self.fullname] = self # type:ignore def __repr__(self) -> str: @@ -160,6 +173,11 @@ class Module: source: str | None = None kind: str | None = None + def __post_init__(self) -> None: + expr = ast.parse(self.name).body[0] + if isinstance(expr, ast.Expr): + self.doc.type.expr = expr.value + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" @@ -222,28 +240,100 @@ def _create_empty_module() -> Module: return Module(None, name, doc, [], [], None, None) -def iter_objects(obj: Module | Class | Function) -> Iterator[Class | Function]: +def iter_objects(obj: Module | Class | Function) -> Iterator[Module | Class | Function]: """Yield [Class] or [Function] instances.""" - if not isinstance(obj, Module): - yield obj + yield obj for cls in obj.classes: yield from iter_objects(cls) for func in obj.functions: yield from iter_objects(func) -def iter_items(obj: Module | Class | Function) -> Iterator[Item]: - """Yield [Element] instances.""" - for obj_ in iter_objects(obj): - if obj_ is not obj: - yield from iter_items(obj_) +def merge_parameters(obj: Class | Function) -> None: + """Merge parameters.""" + section = get_by_type(obj.doc.sections, Parameters) + if not section: + if not obj.parameters: + return + section = create_parameters([]) + obj.doc.sections.append(section) + # TODO: *args, **kwargs + section.items = list(iter_merged_items(obj.parameters, section.items)) + + +def merge_attributes(obj: Module | Class) -> None: + """Merge attributes.""" + section = get_by_type(obj.doc.sections, Attributes) + if not section: + if not obj.attributes: + return + section = create_attributes([]) + obj.doc.sections.append(section) + section.items = list(iter_merged_items(obj.attributes, section.items)) + + +def merge_raises(obj: Class | Function) -> None: + """Merge raises.""" + section = get_by_type(obj.doc.sections, Raises) + if not section: + if not obj.raises: + return + section = create_raises([]) + obj.doc.sections.append(section) + section.items = list(iter_merged_items(obj.raises, section.items)) + + +def merge_returns(obj: Function) -> None: + """Merge returns.""" + section = get_by_type(obj.doc.sections, Returns) + if not section: + if not obj.returns: + return + # TODO: yields + section = Returns("Returns", Type(None), Text(None), []) + obj.doc.sections.append(section) + section.items = list(iter_merged_items(obj.returns, section.items)) + + +def merge_bases(obj: Class) -> None: + """Merge bases.""" + if not obj.bases: + return + section = Bases("Bases", Type(None), Text(None), obj.bases) + obj.doc.sections.insert(0, section) + + +def _merge_items(obj: Module | Class | Function) -> None: if isinstance(obj, Function | Class): - yield from obj.parameters - yield from obj.raises + merge_parameters(obj) + merge_raises(obj) if isinstance(obj, Function): - yield from obj.returns + merge_returns(obj) if isinstance(obj, Module | Class): - yield from obj.attributes + merge_attributes(obj) if isinstance(obj, Class): - yield from obj.bases - yield from obj.doc + merge_bases(obj) + + +def merge_items(obj: Module | Class | Function) -> None: + """Merge items.""" + for obj_ in iter_objects(obj): + _merge_items(obj_) + + +def iter_items(obj: Module | Class | Function) -> Iterator[Item]: + """Yield [Item] instances.""" + for obj_ in iter_objects(obj): + yield from obj_.doc + + +def iter_types(obj: Module | Class | Function) -> Iterator[Type]: + """Yield [Type] instances.""" + for obj_ in iter_objects(obj): + yield from obj_.doc.iter_types() + + +def iter_texts(obj: Module | Class | Function) -> Iterator[Text]: + """Yield [Text] instances.""" + for obj_ in iter_objects(obj): + yield from obj_.doc.iter_texts() diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index ba9a4232..ff7bd17e 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -195,6 +195,13 @@ def get_by_kind[T](items: Iterable[T], kind: str) -> T | None: # noqa: D103 return get_by_name(items, kind, attr="kind") +def get_by_type[T](items: Iterable, type_: type[T]) -> T | None: # noqa: D103 + for item in items: + if isinstance(item, type_): + return item + return None + + def del_by_name[T](items: list[T], name: str, attr: str = "name") -> None: # noqa: D103 for k, item in enumerate(items): if getattr(item, attr, None) == name: diff --git a/tests/test_items.py b/tests/test_items.py index 7e4c65e8..55e16974 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -1,4 +1,5 @@ import ast +import inspect import sys from inspect import Parameter from pathlib import Path @@ -14,10 +15,12 @@ _iter_imports, iter_attributes, iter_bases, + iter_merged_items, iter_parameters, iter_raises, iter_returns, ) +from mkapi.objects import create_module from mkapi.utils import get_module_path @@ -288,3 +291,26 @@ def test_create_bases(): assert base.name == "C" assert isinstance(base.type.expr, ast.Subscript) assert isinstance(base.type.expr.slice, ast.Name) + + +def test_iter_merged_items(): + """'''test''' + def f(x: int=0): + '''function. + + Args: + x: parameter.''' + """ + src = inspect.getdoc(test_iter_merged_items) + assert src + node = ast.parse(src) + module = create_module(node, "x") + func = module.get_function("f") + assert func + items_ast = func.parameters + items_doc = func.doc.sections[0].items + item = next(iter_merged_items(items_ast, items_doc)) + assert item.name == "x" + assert item.type.expr.id == "int" # type: ignore + assert item.default.value == 0 # type: ignore + assert item.text.str == "parameter." diff --git a/tests/test_objects.py b/tests/test_objects.py index 012c4b29..7877ed50 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -7,7 +7,7 @@ from mkapi.ast import iter_child_nodes from mkapi.docstrings import Docstring -from mkapi.items import Item, Return, Section +from mkapi.items import Attributes, Item, Parameters, Raises, Return, Returns, Section from mkapi.objects import ( Class, Function, @@ -16,6 +16,11 @@ create_module, iter_items, iter_objects, + iter_texts, + iter_types, + merge_items, + merge_parameters, + merge_returns, objects, ) from mkapi.utils import get_by_name, get_module_path @@ -129,6 +134,40 @@ def test_relative_import(): assert i.fullname == "x.y.e.f" +def test_merge_items(): + """'''test''' + def f(x: int=0, y: str='s')->bool: + '''function. + + Args: + x: parameter x. + z: parameter z. + + Returns: + Return True.''' + """ + src = inspect.getdoc(test_merge_items) + assert src + node = ast.parse(src) + module = create_module(node, "x") + func = module.get_function("f") + assert func + merge_parameters(func) + assert get_by_name(func.parameters, "x") + assert get_by_name(func.parameters, "y") + assert not get_by_name(func.parameters, "z") + items = func.doc.get("Parameters").items # type: ignore + assert get_by_name(items, "x") + assert get_by_name(items, "y") + assert get_by_name(items, "z") + assert [item.name for item in items] == ["x", "y", "z"] + merge_returns(func) + assert func.returns[0].type + assert func.returns[0].text.str == "Return True." + item = func.doc.get("Returns").items[0] # type: ignore + assert item.type.expr.id == "bool" # type: ignore + + def test_iter(): """'''test module.''' m: str @@ -137,13 +176,13 @@ def test_iter(): class A(D): '''class. - Args: + Attributes: a: attribute a. ''' a: int - def f(x: int, y: str) -> bool: + def f(x: int, y: str) -> list[str]: '''function.''' - class B(E): + class B(E,F.G): c: list raise ValueError """ @@ -158,16 +197,23 @@ class B(E): cls = func.get_class("B") assert cls assert cls.fullname == "x.A.f.B" - objs = iter_objects(module) + assert next(objs).name == "x" assert next(objs).name == "A" assert next(objs).name == "f" assert next(objs).name == "B" + merge_items(module) items = list(iter_items(module)) for x in "mnaxyc": assert get_by_name(items, x) + for x in ["x.A.f.B", "x.A.f", "F.G"]: + assert get_by_name(items, x) assert get_by_name(items, "ValueError") assert any(isinstance(x, Return) for x in items) + assert any(isinstance(x, Docstring) for x in items) assert any(isinstance(x, Item) for x in items) assert any(isinstance(x, Section) for x in items) - assert any(isinstance(x, Docstring) for x in items) + assert any(isinstance(x, Parameters) for x in items) + assert any(isinstance(x, Attributes) for x in items) + assert any(isinstance(x, Raises) for x in items) + assert any(isinstance(x, Returns) for x in items) From 160cfaf4840a588d377ae86a4c1535aca15fd72a Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sun, 14 Jan 2024 17:06:15 +0900 Subject: [PATCH 094/148] set_markdown --- src/mkapi/dataclasses.py | 60 ------ src/mkapi/docstrings.py | 22 +- src/mkapi/importlib.py | 354 +++++++++++++++++-------------- src/mkapi/items.py | 1 - src/mkapi/objects.py | 7 +- tests/dataclasses/__init__.py | 0 tests/dataclasses/test_deco.py | 39 ---- tests/dataclasses/test_params.py | 46 ---- tests/docstrings/test_merge.py | 39 +--- tests/objects/test_class.py | 46 ---- tests/objects/test_inherit.py | 60 ------ tests/objects/test_link.py | 72 ------- tests/objects/test_merge.py | 133 ------------ tests/test_importlib.py | 205 +++++++++++++----- tests/test_items.py | 19 ++ tests/test_objects.py | 13 +- 16 files changed, 394 insertions(+), 722 deletions(-) delete mode 100644 src/mkapi/dataclasses.py delete mode 100644 tests/dataclasses/__init__.py delete mode 100644 tests/dataclasses/test_deco.py delete mode 100644 tests/dataclasses/test_params.py delete mode 100644 tests/objects/test_class.py delete mode 100644 tests/objects/test_inherit.py delete mode 100644 tests/objects/test_link.py delete mode 100644 tests/objects/test_merge.py diff --git a/src/mkapi/dataclasses.py b/src/mkapi/dataclasses.py deleted file mode 100644 index 5ce48366..00000000 --- a/src/mkapi/dataclasses.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Dataclass function.""" -from __future__ import annotations - -import ast -import importlib -import inspect -from typing import TYPE_CHECKING - -from mkapi.ast import iter_identifiers - -if TYPE_CHECKING: - from inspect import _ParameterKind - from typing import Any - - from mkapi.objects import Attribute, Class, Iterator, Module - - -def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: - for deco in cls.node.decorator_list: - name = next(iter_identifiers(deco)) - if module.get_fullname(name) == "dataclasses.dataclass": - return deco - return None - - -def is_dataclass(cls: Class, module: Module | None = None) -> bool: - """Return True if the class is a dataclass.""" - if module := module or cls.module: - return _get_dataclass_decorator(cls, module) is not None - return False - - -def iter_parameters(cls: Class) -> Iterator[tuple[Attribute, _ParameterKind]]: - """Yield tuples of ([Attribute], [_ParameterKind]) for dataclass signature.""" - if not cls.module or not (modulename := cls.module.name): - raise NotImplementedError - try: - module = importlib.import_module(modulename) - except ModuleNotFoundError: - return - members = dict(inspect.getmembers(module, inspect.isclass)) - obj = members[cls.name] - - for param in inspect.signature(obj).parameters.values(): - if attr := cls.get_attribute(param.name): - yield attr, param.kind - - -# -------------------------------------------------------------- - - -def _iter_decorator_args(deco: ast.expr) -> Iterator[tuple[str, Any]]: - for child in ast.iter_child_nodes(deco): - if isinstance(child, ast.keyword): # noqa: SIM102 - if child.arg and isinstance(child.value, ast.Constant): - yield child.arg, child.value.value - - -def _get_decorator_args(deco: ast.expr) -> dict[str, Any]: - return dict(_iter_decorator_args(deco)) diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 5ab7c388..ae493cc9 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -15,6 +15,7 @@ create_parameters, create_raises, create_returns, + iter_merged_items, ) from mkapi.utils import ( add_admonition, @@ -259,21 +260,6 @@ def parse(doc: str | None, style: Style | None = None) -> Docstring: return Docstring("", Type(None), text, sections) -def iter_merged_items(a: list[Item], b: list[Item]) -> Iterator[Item]: - """Yield merged [Item] instances from two lists of [Item].""" - for name in unique_names(a, b): - ai, bi = get_by_name(a, name), get_by_name(b, name) - if ai and not bi: - yield ai - elif not ai and bi: - yield bi - elif ai and bi: - name_ = ai.name or bi.name - type_ = ai.type if ai.type.expr else bi.type - text = ai.text if ai.text.str else bi.text - yield Item(name_, type_, text) - - def merge_sections(a: Section, b: Section) -> Section: """Merge two [Section] instances into one [Section] instance.""" if a.name != b.name: @@ -296,11 +282,11 @@ def iter_merge_sections(a: list[Section], b: list[Section]) -> Iterator[Section] yield merge_sections(ai, bi) -def merge(a: Docstring | None, b: Docstring | None) -> Docstring | None: +def merge(a: Docstring, b: Docstring) -> Docstring: """Merge two [Docstring] instances into one [Docstring] instance.""" - if not a or not a.sections: + if not a.sections: return b - if not b or not b.sections: + if not b.sections: return a sections: list[Section] = [] for ai in a.sections: diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 66aa3a9a..4cc46a08 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -2,18 +2,33 @@ from __future__ import annotations import ast +import importlib +import inspect import re from typing import TYPE_CHECKING -import mkapi.dataclasses -from mkapi import docstrings +import mkapi.ast +import mkapi.docstrings +from mkapi.ast import iter_identifiers +from mkapi.items import Parameter +from mkapi.objects import ( + Class, + Module, + create_module, + iter_texts, + iter_types, + merge_items, + objects, +) +from mkapi.utils import del_by_name, get_module_path, iter_parent_modulenames if TYPE_CHECKING: from collections.abc import Iterator - from typing import Self + from inspect import _ParameterKind - from mkapi.docstrings import Docstring, Item, Section - from mkapi.items import Attribute, Import, Parameter, Raise, Return + from mkapi.objects import Attribute, Function + +modules: dict[str, Module | None] = {} def load_module(name: str) -> Module | None: @@ -28,166 +43,195 @@ def load_module(name: str) -> Module | None: module = load_module_from_source(source, name) module.kind = "package" if path.stem == "__init__" else "module" modules[name] = module + _postprocess(module) return module def load_module_from_source(source: str, name: str = "__mkapi__") -> Module: """Return a [Module] instance from a source string.""" node = ast.parse(source) - module = create_module_from_node(node, name) + module = create_module(node, name) module.source = source return module -# def get_object(fullname: str) -> Module | Class | Function | None: -# """Return an [Object] instance by the fullname.""" -# if fullname in objects: -# return objects[fullname] -# for modulename in iter_parent_modulenames(fullname): -# if load_module(modulename) and fullname in objects: -# return objects[fullname] -# objects[fullname] = None -# return None +def get_object(fullname: str) -> Module | Class | Function | None: + """Return an [Object] instance by the fullname.""" + if fullname in modules: + return modules[fullname] + if fullname in objects: + return objects[fullname] + for modulename in iter_parent_modulenames(fullname): + if load_module(modulename) and fullname in objects: + return objects[fullname] + objects[fullname] = None + return None + + +def get_fullname(module: Module, name: str) -> str | None: + """Return the fullname of an object in the module.""" + if obj := module.get_member(name): + return obj.fullname + if "." in name: + name, attr = name.rsplit(".", maxsplit=1) + if attr_ := module.get_attribute(name): + return f"{module.name}.{attr_.name}" + if import_ := module.get_import(name): # noqa: SIM102 + if module_ := load_module(import_.fullname): + return get_fullname(module_, attr) + return None + + +def _postprocess(obj: Module | Class) -> None: + if isinstance(obj, Module): + merge_items(obj) + set_markdown(obj) + if isinstance(obj, Class): + _postprocess_class(obj) + for cls in obj.classes: + _postprocess(cls) + + +def _postprocess_class(cls: Class) -> None: + inherit_base_classes(cls) + if init := cls.get_function("__init__"): + cls.parameters = init.parameters + cls.raises = init.raises + cls.doc = mkapi.docstrings.merge(cls.doc, init.doc) + del_by_name(cls.functions, "__init__") + if is_dataclass(cls): + cls.parameters = list(iter_dataclass_parameters(cls)) + + +def iter_base_classes(cls: Class) -> Iterator[Class]: + """Yield base classes. + + This function is called in postprocess for inheritance. + """ + if not cls.module: + return + for node in cls.node.bases: + name = next(mkapi.ast.iter_identifiers(node)) + if fullname := get_fullname(cls.module, name): + base = get_object(fullname) + if base and isinstance(base, Class): + yield base + + +def inherit_base_classes(cls: Class) -> None: + """Inherit objects from base classes.""" + # TODO: fix InitVar, ClassVar for dataclasses. + bases = list(iter_base_classes(cls)) + for name in ["attributes", "functions", "classes"]: + members = {} + for base in bases: + for member in getattr(base, name): + members[member.name] = member + for member in getattr(cls, name): + members[member.name] = member + setattr(cls, name, list(members.values())) + + +def _get_dataclass_decorator(cls: Class, module: Module) -> ast.expr | None: + for deco in cls.node.decorator_list: + name = next(iter_identifiers(deco)) + if get_fullname(module, name) == "dataclasses.dataclass": + return deco + return None + + +def is_dataclass(cls: Class, module: Module | None = None) -> bool: + """Return True if the class is a dataclass.""" + if module := module or cls.module: + return _get_dataclass_decorator(cls, module) is not None + return False + + +def iter_dataclass_parameters(cls: Class) -> Iterator[Parameter]: + """Yield [Parameter] instances for dataclass signature.""" + if not cls.module or not (modulename := cls.module.name): + raise NotImplementedError + try: + module = importlib.import_module(modulename) + except ModuleNotFoundError: + return + members = dict(inspect.getmembers(module, inspect.isclass)) + obj = members[cls.name] + + for param in inspect.signature(obj).parameters.values(): + if attr := cls.get_attribute(param.name): + args = (attr.name, attr.type, attr.text, attr.default) + yield Parameter(*args, param.kind) + else: + raise NotImplementedError + + +# def _iter_decorator_args(deco: ast.expr) -> Iterator[tuple[str, Any]]: +# for child in ast.iter_child_nodes(deco): +# if isinstance(child, ast.keyword): +# if child.arg and isinstance(child.value, ast.Constant): +# yield child.arg, child.value.value + + +# def _get_decorator_args(deco: ast.expr) -> dict[str, Any]: +# return dict(_iter_decorator_args(deco)) + + +LINK_PATTERN = re.compile(r"(? None: # noqa: C901 + """Set markdown with link form.""" + cache: dict[str, str] = {} + + def _get_link_type(name: str, asname: str | None = None) -> str: + asname = asname or name + if name in cache: + return cache[name] + fullname = get_fullname(module, name) + link = f"[{asname}][__mkapi__.{fullname}]" if fullname else asname + cache[name] = link + return link + + def get_link_type(name: str) -> str: + names = [] + parents = iter_parent_modulenames(name) + for name_, asname in zip(parents, name.split("."), strict=True): + names.append(_get_link_type(name_, asname)) + return ".".join(names) + + def get_link_text(match: re.Match) -> str: + name = match.group(1) + link = get_link_type(name) + if name != link: + return link + return match.group() + + for type_ in iter_types(module): + if type_.expr: + type_.markdown = mkapi.ast.unparse(type_.expr, get_link_type) + for text in iter_texts(module): + if text.str: + text.markdown = re.sub(LINK_PATTERN, get_link_text, text.str) + + +# def set_markdown(self) -> None: +# """Set markdown with link form.""" +# for type_ in self.types: +# type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) +# for text in self.texts: +# text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) + +# def _get_link_type(self, name: str) -> str: +# if fullname := self.get_fullname(name): +# return get_link(name, fullname) +# return name + +# def _get_link_text(self, match: re.Match) -> str: +# name = match.group(1) +# if fullname := self.get_fullname(name): +# return get_link(name, fullname) +# return match.group() # LINK_PATTERN = re.compile(r"(? str: -# """Return a markdown link.""" -# return f"[{name}][__mkapi__.{fullname}]" - - -# modules: dict[str, Module | None] = {} - - -# # def _postprocess(obj: Module | Class) -> None: -# # _merge_docstring(obj) -# # for func in obj.functions: -# # _merge_docstring(func) -# # for cls in obj.classes: -# # _postprocess(cls) -# # _postprocess_class(cls) - - -# # def _merge_item(obj: Attribute | Parameter | Return | Raise, item: Item) -> None: -# # if not obj.type and item.type: -# # # ex. list(str) -> list[str] -# # type_ = item.type.replace("(", "[").replace(")", "]") -# # obj.type = Type(mkapi.ast.create_expr(type_)) -# # obj.text = Text(item.text) # Does item.text win? - - -# # def _new( -# # cls: type[Attribute | Parameter | Raise], -# # name: str, -# # ) -> Attribute | Parameter | Raise: -# # args = (None, name, None, None) -# # if cls is Attribute: -# # return Attribute(*args, None) -# # if cls is Parameter: -# # return Parameter(*args, None, None) -# # if cls is Raise: -# # return Raise(*args) -# # raise NotImplementedError - - -# # def _merge_items(cls: type, attrs: list, items: list[Item]) -> list: -# # names = unique_names(attrs, items) -# # attrs_ = [] -# # for name in names: -# # if not (attr := get_by_name(attrs, name)): -# # attr = _new(cls, name) -# # attrs_.append(attr) -# # if not (item := get_by_name(items, name)): -# # continue -# # _merge_item(attr, item) # type: ignore -# # return attrs_ - - -# # def _merge_docstring(obj: Module | Class | Function) -> None: -# # """Merge [Object] and [Docstring].""" -# # if not obj.text: -# # return -# # sections: list[Section] = [] -# # for section in docstrings.parse(obj.text.str): -# # if section.name == "Attributes" and isinstance(obj, Module | Class): -# # obj.attributes = _merge_items(Attribute, obj.attributes, section.items) -# # elif section.name == "Parameters" and isinstance(obj, Class | Function): -# # obj.parameters = _merge_items(Parameter, obj.parameters, section.items) -# # elif section.name == "Raises" and isinstance(obj, Class | Function): -# # obj.raises = _merge_items(Raise, obj.raises, section.items) -# # elif section.name in ["Returns", "Yields"] and isinstance(obj, Function): -# # _merge_item(obj.returns, section) -# # obj.returns.name = section.name -# # else: -# # sections.append(section) - - -# # ATTRIBUTE_ORDER_DICT = { -# # ast.AnnAssign: 1, -# # ast.Assign: 2, -# # ast.FunctionDef: 3, -# # ast.TypeAlias: 4, -# # } - - -# # def _attribute_order(attr: Attribute) -> int: -# # if not attr.node: -# # return 0 -# # return ATTRIBUTE_ORDER_DICT.get(type(attr.node), 10) - - -# def _iter_base_classes(cls: Class) -> Iterator[Class]: -# """Yield base classes. - -# This function is called in postprocess for setting base classes. -# """ -# if not cls.module: -# return -# for node in cls.node.bases: -# base_name = next(mkapi.ast.iter_identifiers(node)) -# base_fullname = cls.module.get_fullname(base_name) -# if not base_fullname: -# continue -# base = get_object(base_fullname) -# if base and isinstance(base, Class): -# yield base - - -# def _inherit(cls: Class, name: str) -> None: -# # TODO: fix InitVar, ClassVar for dataclasses. -# members = {} -# for base in cls.bases: -# for member in getattr(base, name): -# members[member.name] = member -# for member in getattr(cls, name): -# members[member.name] = member -# setattr(cls, name, list(members.values())) - - -# # def _postprocess_class(cls: Class) -> None: -# # cls.bases = list(_iter_base_classes(cls)) -# # for name in ["attributes", "functions", "classes"]: -# # _inherit(cls, name) -# # if init := cls.get_function("__init__"): -# # cls.parameters = init.parameters -# # cls.raises = init.raises -# # # cls.docstring = docstrings.merge(cls.docstring, init.docstring) -# # cls.attributes.sort(key=_attribute_order) -# # del_by_name(cls.functions, "__init__") -# # if mkapi.dataclasses.is_dataclass(cls): -# # for attr, kind in mkapi.dataclasses.iter_parameters(cls): -# # args = (None, attr.name, None, None, attr.default, kind) -# # parameter = Parameter(*args) -# # parameter.text = attr.text -# # parameter.type = attr.type -# # parameter.module = attr.module -# # cls.parameters.append(parameter) - -# def get_globals(module: Module) -> dict[str, Class | Function | Attribute | Import]: -# members = module.classes + module.functions + module.imports -# globals_ = {member.fullname: member for member in members} -# for attribute in module.attributes: -# globals_[f"{module.name}.{attribute.name}"] = attribute # type: ignore -# return globals_ diff --git a/src/mkapi/items.py b/src/mkapi/items.py index f54fda58..fa363bee 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING import mkapi.ast -import mkapi.dataclasses from mkapi.ast import is_property from mkapi.utils import ( get_by_name, diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 09deda9a..bde0754b 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING import mkapi.ast -import mkapi.dataclasses from mkapi import docstrings from mkapi.docstrings import Docstring from mkapi.items import ( @@ -244,9 +243,11 @@ def iter_objects(obj: Module | Class | Function) -> Iterator[Module | Class | Fu """Yield [Class] or [Function] instances.""" yield obj for cls in obj.classes: - yield from iter_objects(cls) + if isinstance(obj, Module) or cls.module is obj.module: + yield from iter_objects(cls) for func in obj.functions: - yield from iter_objects(func) + if isinstance(obj, Module) or func.module is obj.module: + yield from iter_objects(func) def merge_parameters(obj: Class | Function) -> None: diff --git a/tests/dataclasses/__init__.py b/tests/dataclasses/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/dataclasses/test_deco.py b/tests/dataclasses/test_deco.py deleted file mode 100644 index 769a1a35..00000000 --- a/tests/dataclasses/test_deco.py +++ /dev/null @@ -1,39 +0,0 @@ -from mkapi.dataclasses import ( - _get_dataclass_decorator, - _iter_decorator_args, - is_dataclass, -) -from mkapi.objects import load_module_from_source - -source = """ -import dataclasses -from dataclasses import dataclass, field -@f() -@dataclasses.dataclass -@g -class A: - pass -@dataclass(init=True,repr=False) -class B: - x: list[A]=field(init=False) - y: int - z: str=field() -""" - - -def test_decorator_arg(): - module = load_module_from_source(source) - cls = module.get_class("A") - assert cls - assert is_dataclass(cls, module) - deco = _get_dataclass_decorator(cls, module) - assert deco - assert not list(_iter_decorator_args(deco)) - cls = module.get_class("B") - assert cls - assert is_dataclass(cls, module) - deco = _get_dataclass_decorator(cls, module) - assert deco - deco_dict = dict(_iter_decorator_args(deco)) - assert deco_dict["init"] - assert not deco_dict["repr"] diff --git a/tests/dataclasses/test_params.py b/tests/dataclasses/test_params.py deleted file mode 100644 index 993ec5b5..00000000 --- a/tests/dataclasses/test_params.py +++ /dev/null @@ -1,46 +0,0 @@ -import ast - -from mkapi.dataclasses import is_dataclass -from mkapi.objects import Class, Parameter, get_object - - -def test_parameters(): - cls = get_object("mkapi.objects.Class") - assert isinstance(cls, Class) - assert is_dataclass(cls) - p = cls.parameters - assert len(p) == 6 - assert p[0].name == "node" - assert p[0].type - assert ast.unparse(p[0].type.expr) == "ast.ClassDef" - assert p[1].name == "name" - assert p[1].type - assert ast.unparse(p[1].type.expr) == "str" - assert p[2].name == "_text" - assert p[2].type - assert ( - ast.unparse(p[2].type.expr) == "InitVar[str | None]" - ) # TODO: Delete `InitVar` - assert p[3].name == "_type" - assert p[3].type - assert ( - ast.unparse(p[3].type.expr) == "InitVar[ast.expr | None]" - ) # TODO: Delete `InitVar` - assert p[4].name == "parameters" - assert p[4].type - assert ast.unparse(p[4].type.expr) == "list[Parameter]" - assert p[5].name == "raises" - assert p[5].type - assert ast.unparse(p[5].type.expr) == "list[Raise]" - # assert p[6].name == "attributes" - # assert p[6].type - # assert ast.unparse(p[6].type.expr) == "list[Attribute]" - # assert p[7].name == "classes" - # assert p[7].type - # assert ast.unparse(p[7].type.expr) == "list[Class]" - # assert p[8].name == "functions" - # assert p[8].type - # assert ast.unparse(p[8].type.expr) == "list[Function]" - # assert p[9].name == "bases" - # assert p[9].type - # assert ast.unparse(p[9].type.expr) == "list[Class]" diff --git a/tests/docstrings/test_merge.py b/tests/docstrings/test_merge.py index d9284705..27abc841 100644 --- a/tests/docstrings/test_merge.py +++ b/tests/docstrings/test_merge.py @@ -1,32 +1,11 @@ -import ast +# from mkapi.docstrings import merge, parse -from mkapi.docstrings import Item, Text, Type, iter_merged_items, merge, parse - -def test_iter_merged_items(): - a = [ - Item("a", Type(None), Text("item a")), - Item("b", Type(ast.Constant("int")), Text("item b")), - ] - b = [ - Item("a", Type(ast.Constant("str")), Text("item A")), - Item("c", Type(ast.Constant("list")), Text("item c")), - ] - c = list(iter_merged_items(a, b)) - assert c[0].name == "a" - assert c[0].type.expr.value == "str" # type: ignore - assert c[0].text.str == "item a" - assert c[1].name == "b" - assert c[1].type.expr.value == "int" # type: ignore - assert c[2].name == "c" - assert c[2].type.expr.value == "list" # type: ignore - - -def test_merge(google, get, get_node): - a = parse(get(google, "ExampleClass")) - b = parse(get(get_node(google, "ExampleClass"), "__init__")) - doc = merge(a, b) - assert doc - names = ["", "Attributes", "Note", "Parameters", ""] - assert [s.name for s in doc.sections] == names - doc.sections[-1].text.str.endswith("with it.") # type: ignore +# def test_merge(google, get, get_node): +# a = parse(get(google, "ExampleClass")) +# b = parse(get(get_node(google, "ExampleClass"), "__init__")) +# doc = merge(a, b) +# assert doc +# names = ["", "Attributes", "Note", "Parameters", ""] +# assert [s.name for s in doc.sections] == names +# doc.sections[-1].text.str.endswith("with it.") # type: ignore diff --git a/tests/objects/test_class.py b/tests/objects/test_class.py deleted file mode 100644 index fed65247..00000000 --- a/tests/objects/test_class.py +++ /dev/null @@ -1,46 +0,0 @@ -from mkapi.objects import Class, _inherit, _iter_base_classes, get_object, load_module -from mkapi.utils import get_by_name - - -def test_baseclasses(): - cls = get_object("mkapi.plugins.MkAPIPlugin") - assert isinstance(cls, Class) - assert cls.qualname == "MkAPIPlugin" - assert cls.fullname == "mkapi.plugins.MkAPIPlugin" - func = cls.get_function("on_config") - assert func - assert func.qualname == "MkAPIPlugin.on_config" - assert func.fullname == "mkapi.plugins.MkAPIPlugin.on_config" - base = next(_iter_base_classes(cls)) - assert base.name == "BasePlugin" - assert base.fullname == "mkdocs.plugins.BasePlugin" - func = base.get_function("on_config") - assert func - assert func.qualname == "BasePlugin.on_config" - assert func.fullname == "mkdocs.plugins.BasePlugin.on_config" - cls = get_object("mkapi.plugins.MkAPIConfig") - assert isinstance(cls, Class) - base = next(_iter_base_classes(cls)) - assert base.name == "Config" - assert base.qualname == "Config" - assert base.fullname == "mkdocs.config.base.Config" - - -def test_iter_bases(): - module = load_module("mkapi.objects") - assert module - cls = module.get_class("Class") - assert cls - cls.bases = list(_iter_base_classes(cls)) - bases = cls.iter_bases() - assert next(bases).name == "Object" - assert next(bases).name == "Class" - - -def test_inherit(): - cls = get_object("mkapi.plugins.MkAPIConfig") - assert isinstance(cls, Class) - cls.bases = list(_iter_base_classes(cls)) - _inherit(cls, "attributes") - assert cls.bases[0].fullname == "mkdocs.config.base.Config" - assert get_by_name(cls.attributes, "config_file_path") diff --git a/tests/objects/test_inherit.py b/tests/objects/test_inherit.py deleted file mode 100644 index df1c1311..00000000 --- a/tests/objects/test_inherit.py +++ /dev/null @@ -1,60 +0,0 @@ -import ast - -from mkapi.objects import Class, get_object, load_module -from mkapi.utils import get_by_name - - -def test_inherit(): - cls = get_object("mkapi.objects.Class") - assert isinstance(cls, Class) - assert cls.bases[0] - assert cls.bases[0].name == "Callable" - assert cls.bases[0].fullname == "mkapi.objects.Callable" - a = cls.attributes - for x in a: - print(x) - assert 0 - # TODO: fix InitVar, ClassVar - assert len(a) == 14 - assert get_by_name(a, "node") - assert get_by_name(a, "qualname") - assert get_by_name(a, "fullname") - assert get_by_name(a, "module") - assert get_by_name(a, "classnames") - p = cls.parameters - assert len(p) == 11 - assert get_by_name(p, "node") - assert not get_by_name(p, "qualname") - assert not get_by_name(p, "fullname") - assert not get_by_name(p, "module") - assert not get_by_name(p, "classnames") - - -def test_inherit_other_module2(): - cls = get_object("mkapi.plugins.MkAPIPlugin") - assert isinstance(cls, Class) - f = cls.functions - x = get_by_name(f, "on_pre_template") - assert x - p = x.parameters[1] - assert p.node - assert ast.unparse(p.node) == "template: jinja2.Template" - m = p.module - assert m - assert m.name == "mkdocs.plugins" - assert m.get_member("get_plugin_logger") - - -def test_inherit_other_module3(): - m1 = load_module("mkdocs.plugins") - assert m1 - a = "mkdocs.utils.templates.TemplateContext" - assert m1.get_fullname("TemplateContext") == a - assert m1.get_fullname("jinja2") == "jinja2" - a = "jinja2.environment.Template" - assert m1.get_fullname("jinja2.Template") == a - m2 = load_module("jinja2") - assert m2 - x = m2.get_member("Template") - assert x - assert x.fullname == a diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py deleted file mode 100644 index 8a3a64d3..00000000 --- a/tests/objects/test_link.py +++ /dev/null @@ -1,72 +0,0 @@ -import re - -from mkapi.objects import LINK_PATTERN, load_module - - -def test_set_markdown_objects(): - module = load_module("mkapi.objects") - assert module - x = [t.markdown for t in module.types] - assert "list[[Item][__mkapi__.mkapi.docstrings.Item]]" in x - assert "[Path][__mkapi__.pathlib.Path] | None" in x - assert "NotImplementedError" in x - assert "[InitVar][__mkapi__.dataclasses.InitVar][str | None]" in x - - -def test_set_markdown_plugins(): - module = load_module("mkapi.plugins") - assert module - x = [t.markdown for t in module.types] - assert "[MkDocsConfig][__mkapi__.mkdocs.config.defaults.MkDocsConfig]" in x - assert "[MkDocsPage][__mkapi__.mkdocs.structure.pages.Page]" in x - assert "[MkAPIConfig][__mkapi__.mkapi.plugins.MkAPIConfig]" in x - assert "[TypeGuard][__mkapi__.typing.TypeGuard][str]" in x - assert "[Callable][__mkapi__.collections.abc.Callable] | None" in x - - -def test_set_markdown_bases(): - module = load_module("mkapi.plugins") - assert module - cls = module.get_class("MkAPIConfig") - assert cls - assert cls.bases - cls = cls.bases[0] - module = cls.module - assert module - x = [t.markdown for t in module.types] - assert "[Config][__mkapi__.mkdocs.config.base.Config]" in x - assert "[T][__mkapi__.mkdocs.config.base.T]" in x - assert "[ValidationError][__mkapi__.mkdocs.config.base.ValidationError]" in x - assert "[IO][__mkapi__.typing.IO]" in x - assert "[PlainConfigSchema][__mkapi__.mkdocs.config.base.PlainConfigSchema]" in x - assert "str | [IO][__mkapi__.typing.IO] | None" in x - assert "[Iterator][__mkapi__.typing.Iterator][[IO][__mkapi__.typing.IO]]" in x - - -def test_link_pattern(): - def f(m: re.Match) -> str: - name = m.group(1) - if name == "abc": - return f"[{name}][_{name}]" - return m.group() - - assert re.search(LINK_PATTERN, "X[abc]Y") - assert not re.search(LINK_PATTERN, "X[ab c]Y") - assert re.search(LINK_PATTERN, "X[abc][]Y") - assert not re.search(LINK_PATTERN, "X[abc](xyz)Y") - assert not re.search(LINK_PATTERN, "X[abc][xyz]Y") - assert re.sub(LINK_PATTERN, f, "X[abc]Y") == "X[abc][_abc]Y" - assert re.sub(LINK_PATTERN, f, "X[abc[abc]]Y") == "X[abc[abc][_abc]]Y" - assert re.sub(LINK_PATTERN, f, "X[ab]Y") == "X[ab]Y" - assert re.sub(LINK_PATTERN, f, "X[ab c]Y") == "X[ab c]Y" - assert re.sub(LINK_PATTERN, f, "X[abc] c]Y") == "X[abc][_abc] c]Y" - assert re.sub(LINK_PATTERN, f, "X[abc][]Y") == "X[abc][_abc]Y" - assert re.sub(LINK_PATTERN, f, "X[abc](xyz)Y") == "X[abc](xyz)Y" - assert re.sub(LINK_PATTERN, f, "X[abc][xyz]Y") == "X[abc][xyz]Y" - - -def test_set_markdown_text(): - module = load_module("mkapi.objects") - assert module - x = [t.markdown for t in module.texts] - assert "Add a [Type][__mkapi__.mkapi.objects.Type] instance." in x diff --git a/tests/objects/test_merge.py b/tests/objects/test_merge.py deleted file mode 100644 index 6e29a65b..00000000 --- a/tests/objects/test_merge.py +++ /dev/null @@ -1,133 +0,0 @@ -import ast - -import pytest - -from mkapi.objects import ( - Attribute, - Class, - Function, - Module, - Parameter, - load_module, - modules, -) -from mkapi.utils import get_by_name - - -def test_split_attribute_docstring(google): - name = "module_level_variable2" - node = google.get_member(name) - assert isinstance(node, Attribute) - assert node.text - text = node.text.str - assert isinstance(text, str) - assert text.startswith("Module level") - assert text.endswith("by a colon.") - assert node.type - assert isinstance(node.type.expr, ast.Name) - assert node.type.expr.id == "int" - - -def test_move_property_to_attributes(google): - cls = google.get_member("ExampleClass") - attr = cls.get_attribute("readonly_property") - assert isinstance(attr, Attribute) - assert attr.text - assert attr.text.str.startswith("Properties should be") - assert attr.type - assert ast.unparse(attr.type.expr) == "str" - attr = cls.get_attribute("readwrite_property") - assert isinstance(attr, Attribute) - assert attr.text - assert attr.text.str.endswith("mentioned here.") - assert attr.type - assert ast.unparse(attr.type.expr) == "list[str]" - - -@pytest.fixture() -def module(): - name = "examples.styles.example_google" - if name in modules: - del modules[name] - return load_module(name) - - -def test_merge_module_attrs(module: Module): - x = module.get_attribute("module_level_variable1") - assert isinstance(x, Attribute) - assert x.text - assert x.text.str.startswith("Module level") - assert x.type - assert isinstance(x.type.expr, ast.Name) - assert isinstance(x.default, ast.Constant) - - -def test_merge_function_args(module: Module): - f = module.get_function("function_with_types_in_docstring") - assert isinstance(f, Function) - assert f.text - p = get_by_name(f.parameters, "param1") - assert isinstance(p, Parameter) - assert p.type - assert isinstance(p.type.expr, ast.Name) - assert p.text - assert isinstance(p.text.str, str) - assert p.text.str.startswith("The first") - - -def test_merge_function_returns(module: Module): - f = module.get_function("function_with_types_in_docstring") - assert isinstance(f, Function) - r = f.returns - assert r.name == "Returns" - assert r.type - assert isinstance(r.type.expr, ast.Name) - assert ast.unparse(r.type.expr) == "bool" - assert r.text - assert isinstance(r.text.str, str) - assert r.text.str.startswith("The return") - - -def test_merge_function_pep484(module: Module): - f = module.get_function("function_with_pep484_type_annotations") - assert f - x = f.get_parameter("param1") - assert x - assert x.text - assert x.text.str.startswith("The first") - - -def test_merge_generator(module: Module): - g = module.get_function("example_generator") - assert g - assert g.returns.name == "Yields" - - -def test_postprocess_class(module: Module): - c = module.get_class("ExampleError") - assert isinstance(c, Class) - assert len(c.parameters) == 3 # with `self` at this state. - assert not c.functions - c = module.get_class("ExampleClass") - assert isinstance(c, Class) - assert len(c.parameters) == 4 # with `self` at this state. - assert c.parameters[3].type - assert ast.unparse(c.parameters[3].type.expr) == "list[str]" - assert c.attributes[0].name == "attr1" - f = c.get_function("example_method") - assert f - assert len(f.parameters) == 3 # with `self` at this state. - - -def test_postprocess_class_pep526(module: Module): - c = module.get_class("ExamplePEP526Class") - assert isinstance(c, Class) - assert len(c.parameters) == 0 - assert c.text - assert not c.functions - assert c.attributes - assert c.attributes[0].name == "attr1" - assert c.attributes[0].type - assert isinstance(c.attributes[0].type.expr, ast.Name) - assert c.attributes[0].text - assert c.attributes[0].text.str == "Description of `attr1`." diff --git a/tests/test_importlib.py b/tests/test_importlib.py index d2416bd7..65b9480f 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -1,44 +1,26 @@ import ast - -import pytest +import re import mkapi.ast +import mkapi.importlib import mkapi.objects -from mkapi.objects import ( - Class, - Function, - Module, - get_module_path, +from mkapi.importlib import ( + LINK_PATTERN, + get_fullname, get_object, + is_dataclass, + iter_base_classes, load_module, - modules, - objects, ) +from mkapi.objects import Class, Function, Module, iter_objects, iter_texts, iter_types +from mkapi.utils import get_by_name -@pytest.fixture(scope="module") -def module(): - path = get_module_path("mkdocs.structure.files") - assert path - with path.open("r", encoding="utf-8") as f: - source = f.read() - return ast.parse(source) - - -def test_not_found(): +def test_module_not_found(): assert load_module("xxx") is None - assert mkapi.objects.modules["xxx"] is None + assert mkapi.importlib.modules["xxx"] is None assert load_module("markdown") - assert "markdown" in mkapi.objects.modules - - -def test_repr(): - module = load_module("mkapi") - assert repr(module) == "Module(mkapi)" - module = load_module("mkapi.objects") - assert repr(module) == "Module(mkapi.objects)" - obj = get_object("mkapi.objects.Object") - assert repr(obj) == "Class(Object)" + assert "markdown" in mkapi.importlib.modules def test_load_module_source(): @@ -59,27 +41,18 @@ def test_load_module_source(): assert "MkAPIPlugin" in src -def test_load_module_from_object(): - module = load_module("mkdocs.structure.files") +def test_module_kind(): + module = load_module("mkapi") assert module - c = module.classes[1] - m = c.module - assert module is m - - -def test_fullname(google: Module): - c = google.get_class("ExampleClass") - assert isinstance(c, Class) - f = c.get_function("example_method") - assert isinstance(f, Function) - assert c.fullname == "examples.styles.example_google.ExampleClass" - name = "examples.styles.example_google.ExampleClass.example_method" - assert f.fullname == name + assert module.kind == "package" + module = load_module("mkapi.objects") + assert module + assert module.kind == "module" -def test_cache(): - modules.clear() - objects.clear() +def test_get_object(): + mkapi.importlib.modules.clear() + mkapi.objects.objects.clear() module = load_module("mkapi.objects") c = get_object("mkapi.objects.Object") f = get_object("mkapi.objects.Module.get_class") @@ -94,25 +67,143 @@ def test_cache(): m1 = load_module("mkdocs.structure.files") m2 = load_module("mkdocs.structure.files") assert m1 is m2 - modules.clear() + mkapi.importlib.modules.clear() m3 = load_module("mkdocs.structure.files") m4 = load_module("mkdocs.structure.files") assert m2 is not m3 assert m3 is m4 -def test_module_kind(): - module = load_module("mkapi") +def test_get_fullname(): + module = load_module("mkapi.plugins") assert module - assert module.kind == "package" + name = "mkdocs.utils.templates.TemplateContext" + assert get_fullname(module, "TemplateContext") == name + name = "mkdocs.config.config_options.Type" + assert get_fullname(module, "config_options.Type") == name + assert not get_fullname(module, "config_options.A") + module = load_module("mkdocs.plugins") + assert module + assert get_fullname(module, "jinja2") == "jinja2" + name = "jinja2.environment.Template" + assert get_fullname(module, "jinja2.Template") == name + + +def test_iter_base_classes(): + cls = get_object("mkapi.plugins.MkAPIPlugin") + assert isinstance(cls, Class) + assert cls.qualname == "MkAPIPlugin" + assert cls.fullname == "mkapi.plugins.MkAPIPlugin" + func = cls.get_function("on_config") + assert func + assert func.qualname == "MkAPIPlugin.on_config" + assert func.fullname == "mkapi.plugins.MkAPIPlugin.on_config" + base = next(iter_base_classes(cls)) + assert base.name == "BasePlugin" + assert base.fullname == "mkdocs.plugins.BasePlugin" + func = base.get_function("on_config") + assert func + assert func.qualname == "BasePlugin.on_config" + assert func.fullname == "mkdocs.plugins.BasePlugin.on_config" + cls = get_object("mkapi.plugins.MkAPIConfig") + assert isinstance(cls, Class) + base = next(iter_base_classes(cls)) + assert base.name == "Config" + assert base.qualname == "Config" + assert base.fullname == "mkdocs.config.base.Config" + + +def test_inherit_base_classes(): + cls = get_object("mkapi.plugins.MkAPIConfig") + assert isinstance(cls, Class) + # inherit_base_classes(cls) + assert get_by_name(cls.attributes, "config_file_path") + cls = get_object("mkapi.plugins.MkAPIPlugin") + assert isinstance(cls, Class) + # inherit_base_classes(cls) + assert get_by_name(cls.functions, "on_page_read_source") + cls = get_object("mkapi.items.Parameters") + assert isinstance(cls, Class) + assert get_by_name(cls.attributes, "name") + assert get_by_name(cls.attributes, "type") + assert get_by_name(cls.attributes, "items") + + +def test_iter_dataclass_parameters(): + cls = get_object("mkapi.items.Parameters") + assert isinstance(cls, Class) + assert is_dataclass(cls) + p = cls.parameters + assert len(p) == 4 + assert p[0].name == "name" + assert p[1].name == "type" + assert p[2].name == "text" + assert p[3].name == "items" + + +def test_link_pattern(): + def f(m: re.Match) -> str: + name = m.group(1) + if name == "abc": + return f"[{name}][_{name}]" + return m.group() + + assert re.search(LINK_PATTERN, "X[abc]Y") + assert not re.search(LINK_PATTERN, "X[ab c]Y") + assert re.search(LINK_PATTERN, "X[abc][]Y") + assert not re.search(LINK_PATTERN, "X[abc](xyz)Y") + assert not re.search(LINK_PATTERN, "X[abc][xyz]Y") + assert re.sub(LINK_PATTERN, f, "X[abc]Y") == "X[abc][_abc]Y" + assert re.sub(LINK_PATTERN, f, "X[abc[abc]]Y") == "X[abc[abc][_abc]]Y" + assert re.sub(LINK_PATTERN, f, "X[ab]Y") == "X[ab]Y" + assert re.sub(LINK_PATTERN, f, "X[ab c]Y") == "X[ab c]Y" + assert re.sub(LINK_PATTERN, f, "X[abc] c]Y") == "X[abc][_abc] c]Y" + assert re.sub(LINK_PATTERN, f, "X[abc][]Y") == "X[abc][_abc]Y" + assert re.sub(LINK_PATTERN, f, "X[abc](xyz)Y") == "X[abc](xyz)Y" + assert re.sub(LINK_PATTERN, f, "X[abc][xyz]Y") == "X[abc][xyz]Y" + + +def test_iter_types(): + module = load_module("mkapi.plugins") + assert module + cls = module.get_class("MkAPIConfig") + assert cls + types = [ast.unparse(x.expr) for x in iter_types(module)] # type: ignore + assert "BasePlugin[MkAPIConfig]" in types + assert "TypeGuard[str]" in types + + +def test_set_markdown_objects(): module = load_module("mkapi.objects") assert module - assert module.kind == "module" + x = [t.markdown for t in iter_types(module)] + assert "[Class][__mkapi__.mkapi.objects.Class] | None" in x + assert "list[[Raise][__mkapi__.mkapi.items.Raise]]" in x -def test_get_fullname_with_attr(): +def test_set_markdown_plugins(): module = load_module("mkapi.plugins") assert module - name = module.get_fullname("config_options.Type") - assert name == "mkdocs.config.config_options.Type" - assert not module.get_fullname("config_options.A") + x = [t.markdown for t in iter_types(module)] + assert "[MkDocsPage][__mkapi__.mkdocs.structure.pages.Page]" in x + assert "tuple[list, list[[Path][__mkapi__.pathlib.Path]]]" in x + + +def test_set_markdown_mkdocs(): + module = load_module("mkdocs.plugins") + assert module + x = [t.markdown for t in iter_types(module)] + link = ( + "[jinja2][__mkapi__.jinja2].[Environment]" + "[__mkapi__.jinja2.environment.Environment]" + ) + assert link in x + + +def test_set_markdown_text(): + module = load_module("mkapi.importlib") + assert module + x = [t.markdown for t in iter_texts(module)] + for i in x: + print(i) + assert any("[Parameter][__mkapi__.mkapi.items.Parameter]" for i in x) diff --git a/tests/test_items.py b/tests/test_items.py index 55e16974..4b202b26 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -314,3 +314,22 @@ def f(x: int=0): assert item.type.expr.id == "int" # type: ignore assert item.default.value == 0 # type: ignore assert item.text.str == "parameter." + + +def test_iter_merged_items_(): + a = [ + Item("a", Type(None), Text("item a")), + Item("b", Type(ast.Constant("int")), Text("item b")), + ] + b = [ + Item("a", Type(ast.Constant("str")), Text("item A")), + Item("c", Type(ast.Constant("list")), Text("item c")), + ] + c = list(iter_merged_items(a, b)) + assert c[0].name == "a" + assert c[0].type.expr.value == "str" # type: ignore + assert c[0].text.str == "item a" + assert c[1].name == "b" + assert c[1].type.expr.value == "int" # type: ignore + assert c[2].name == "c" + assert c[2].type.expr.value == "list" # type: ignore diff --git a/tests/test_objects.py b/tests/test_objects.py index 7877ed50..2772423f 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -16,8 +16,6 @@ create_module, iter_items, iter_objects, - iter_texts, - iter_types, merge_items, merge_parameters, merge_returns, @@ -117,6 +115,17 @@ def test_create_module(google): assert repr(module) == "Module(google)" +def test_fullname(google): + module = create_module(google, "examples.styles.google") + c = module.get_class("ExampleClass") + assert isinstance(c, Class) + f = c.get_function("example_method") + assert isinstance(f, Function) + assert c.fullname == "examples.styles.google.ExampleClass" + name = "examples.styles.google.ExampleClass.example_method" + assert f.fullname == name + + def test_relative_import(): """# test module from .c import d From e7528a3c4d65ef150749f0f2c0a56941f5b719f1 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 15 Jan 2024 07:18:12 +0900 Subject: [PATCH 095/148] plugin --- src/mkapi/importlib.py | 37 ++---- src/mkapi/items.py | 2 + src/mkapi/link.py | 207 ----------------------------- src/mkapi/nodes.py | 175 ++++++++++++------------ src/mkapi/objects.py | 22 ++-- src/mkapi/pages.py | 212 +++++++++++++++++++++--------- src/mkapi/plugins.py | 12 +- src/mkapi/renderers.py | 191 +++++++++++++-------------- src/mkapi/templates/module.jinja2 | 6 - tests/test_importlib.py | 9 ++ tests/test_link.py | 38 ------ tests/test_nodes.py | 33 ----- tests/test_pages.py | 89 ++++--------- 13 files changed, 394 insertions(+), 639 deletions(-) delete mode 100644 src/mkapi/link.py delete mode 100644 tests/test_link.py delete mode 100644 tests/test_nodes.py diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 4cc46a08..3744ceb6 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -73,12 +73,15 @@ def get_fullname(module: Module, name: str) -> str | None: if obj := module.get_member(name): return obj.fullname if "." in name: - name, attr = name.rsplit(".", maxsplit=1) - if attr_ := module.get_attribute(name): + name_, attr = name.rsplit(".", maxsplit=1) + if attr_ := module.get_attribute(name_): return f"{module.name}.{attr_.name}" - if import_ := module.get_import(name): # noqa: SIM102 - if module_ := load_module(import_.fullname): - return get_fullname(module_, attr) + if import_ := module.get_import(name_): # noqa: SIM102 + if module_ := load_module(import_.fullname): # noqa: SIM102 + if fullname := get_fullname(module_, attr): + return fullname + if name.startswith(module.name): + return name return None @@ -185,9 +188,9 @@ def set_markdown(module: Module) -> None: # noqa: C901 cache: dict[str, str] = {} def _get_link_type(name: str, asname: str | None = None) -> str: - asname = asname or name if name in cache: return cache[name] + asname = asname or name fullname = get_fullname(module, name) link = f"[{asname}][__mkapi__.{fullname}]" if fullname else asname cache[name] = link @@ -213,25 +216,3 @@ def get_link_text(match: re.Match) -> str: for text in iter_texts(module): if text.str: text.markdown = re.sub(LINK_PATTERN, get_link_text, text.str) - - -# def set_markdown(self) -> None: -# """Set markdown with link form.""" -# for type_ in self.types: -# type_.markdown = mkapi.ast.unparse(type_.expr, self._get_link_type) -# for text in self.texts: -# text.markdown = re.sub(LINK_PATTERN, self._get_link_text, text.str) - -# def _get_link_type(self, name: str) -> str: -# if fullname := self.get_fullname(name): -# return get_link(name, fullname) -# return name - -# def _get_link_text(self, match: re.Match) -> str: -# name = match.group(1) -# if fullname := self.get_fullname(name): -# return get_link(name, fullname) -# return match.group() - - -# LINK_PATTERN = re.compile(r"(? str: - """Reutrn resolved link. - - Args: - markdown: Markdown source. - abs_src_path: Absolute source path of Markdown. - abs_api_paths: List of API paths. - - Examples: - >>> abs_src_path = '/src/examples/example.md' - >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] - >>> resolve_link('[abc][b.c.d]', abs_src_path, abs_api_paths) - '[abc](../../api/b.c#b.c.d)' - >>> resolve_link('[abc][__mkapi__.b.c.d]', abs_src_path, abs_api_paths) - '[abc](../../api/b.c#b.c.d)' - >>> resolve_link('list[[abc][__mkapi__.b.c.d]]', abs_src_path, abs_api_paths) - 'list[[abc](../../api/b.c#b.c.d)]' - """ - - def replace(match: re.Match) -> str: - name, href = match.groups() - if href.startswith("!!"): # Just for MkAPI documentation. - href = href[2:] - return f"[{name}]({href})" - from_mkapi = False - if href.startswith("__mkapi__."): - href = href[10:] - from_mkapi = True - - if href := _resolve_href(href, abs_src_path, abs_api_paths): - return f"[{name}]({href})" - return name if from_mkapi else match.group() - - return re.sub(LINK_PATTERN, replace, markdown) - - -def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: - if not name: - return "" - abs_api_path = _match_last(name, abs_api_paths) - if not abs_api_path: - return "" - relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) - relpath = relpath.replace("\\", "/") - return f"{relpath}#{name}" - - -def _match_last(name: str, abs_api_paths: list[str]) -> str: - match = "" - for abs_api_path in abs_api_paths: - _, path = os.path.split(abs_api_path) - if name.startswith(path[:-3]): - match = abs_api_path - return match - - -# REPLACE_LINK_PATTERN = re.compile(r"\[(.*?)\]\((.*?)\)|(\S+)_") - - -# def link(name: str, href: str) -> str: -# """Return Markdown link with a mark that indicates this link was created by MkAPI. - -# Args: -# name: Link name. -# href: Reference. - -# Examples: -# >>> link('abc', 'xyz') -# '[abc](!xyz)' -# """ -# return f"[{name}](!{href})" - - -# def get_link(obj: type, *, include_module: bool = False) -> str: -# """Return Markdown link for object, if possible. - -# Args: -# obj: Object -# include_module: If True, link text includes module path. - -# Examples: -# >>> get_link(int) -# 'int' -# >>> get_link(get_fullname) -# '[get_fullname](!mkapi.core.object.get_fullname)' -# >>> get_link(get_fullname, include_module=True) -# '[mkapi.core.object.get_fullname](!mkapi.core.object.get_fullname)' -# """ -# if hasattr(obj, "__qualname__"): -# name = obj.__qualname__ -# elif hasattr(obj, "__name__"): -# name = obj.__name__ -# else: -# msg = f"obj has no name: {obj}" -# warnings.warn(msg, stacklevel=1) -# return str(obj) - -# if not hasattr(obj, "__module__") or (module := obj.__module__) == "builtins": -# return name -# fullname = f"{module}.{name}" -# text = fullname if include_module else name -# if obj.__name__.startswith("_"): -# return text -# return link(text, fullname) - - -# class _ObjectParser(HTMLParser): -# def feed(self, html: str) -> dict[str, Any]: -# self.context = {"href": [], "heading_id": ""} -# super().feed(html) -# href = self.context["href"] -# if len(href) == 2: -# prefix_url, name_url = href -# elif len(href) == 1: -# prefix_url, name_url = "", href[0] -# else: -# prefix_url, name_url = "", "" -# self.context["prefix_url"] = prefix_url -# self.context["name_url"] = name_url -# del self.context["href"] -# return self.context - -# def handle_starttag(self, tag: str, attrs: list[str]) -> None: -# context = self.context -# if tag == "p": -# context["level"] = 0 -# elif re.match(r"h[1-6]", tag): -# context["level"] = int(tag[1:]) -# for attr in attrs: -# if attr[0] == "id": -# self.context["heading_id"] = attr[1] -# elif tag == "a": -# for attr in attrs: -# if attr[0] == "href": -# href = attr[1] -# if href.startswith("./"): -# href = href[2:] -# self.context["href"].append(href) - - -# parser = _ObjectParser() - - -# def resolve_object(html: str) -> dict[str, Any]: -# """Reutrn an object context dictionary. - -# Args: -# html: HTML source. - -# Examples: -# >>> resolve_object("

    pn

    ") -# {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} -# >>> resolve_object("

    pn

    ") -# {'heading_id': 'i', 'level': 2, 'prefix_url': 'a', 'name_url': 'b'} -# """ -# parser.reset() -# return parser.feed(html) - - -# def replace_link(obj: object, markdown: str) -> str: -# """Return a replaced link with object's full name. - -# Args: -# obj: Object that has a module. -# markdown: Markdown - -# Examples: -# >>> from mkapi.core.object import get_object -# >>> obj = get_object('mkapi.core.structure.Object') -# >>> replace_link(obj, '[Signature]()') -# '[Signature](!mkapi.inspect.signature.Signature)' -# >>> replace_link(obj, '[](Signature)') -# '[Signature](!mkapi.inspect.signature.Signature)' -# >>> replace_link(obj, '[text](Signature)') -# '[text](!mkapi.inspect.signature.Signature)' -# >>> replace_link(obj, '[dummy.Dummy]()') -# '[dummy.Dummy]()' -# >>> replace_link(obj, 'Signature_') -# '[Signature](!mkapi.inspect.signature.Signature)' -# """ - -# def replace(match: re.Match) -> str: -# text, name, rest = match.groups() -# if rest: -# name, text = rest, "" -# elif not name: -# name, text = text, "" -# fullname = get_fullname(obj, name) -# if fullname == "": -# return match.group() -# return link(text or name, fullname) - -# return re.sub(REPLACE_LINK_PATTERN, replace, markdown) diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py index e41b5f1f..25bbbca9 100644 --- a/src/mkapi/nodes.py +++ b/src/mkapi/nodes.py @@ -1,89 +1,86 @@ -"""Node class represents Markdown and HTML structure.""" -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from mkapi.objects import ( - Attribute, - Class, - Function, - Module, - Parameter, - Raise, - Return, - get_object, -) -from mkapi.utils import get_by_name - -if TYPE_CHECKING: - from collections.abc import Iterator - - -@dataclass -class Node: - """Node class.""" - - name: str - object: Module | Class | Function | Attribute # noqa: A003 - members: list[Node] = field(default_factory=list, init=False) - parent: Node | None = None - html: str = field(init=False) - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - return f"{class_name}({self.name!r}, members={len(self)})" - - def __len__(self) -> int: - return len(self.members) - - def __contains__(self, name: str) -> bool: - if self.members: - return any(member.object.name == name for member in self.members) - return False - - def get(self, name: str) -> Node | None: # noqa: D102 - return get_by_name(self.members, name) if self.members else None - - def get_kind(self) -> str: - """Returns kind of self.""" - raise NotImplementedError - - def walk(self) -> Iterator[Node]: - """Yields all members.""" - yield self - for member in self.members: - yield from member.walk() - - def get_markdown(self, level: int, filters: list[str]) -> str: - """Returns a Markdown source for docstring of self.""" - markdowns = [] - for node in self.walk(): - markdowns.append(f"{node.object.id}\n\n") # noqa: PERF401 - return "\n\n\n\n".join(markdowns) - - def convert_html(self, html: str, level: int, filters: list[str]) -> str: - htmls = html.split("") - for node, html in zip(self.walk(), htmls, strict=False): - node.html = html.strip() - return "a" - - -def get_node(name: str) -> Node: - """Return a [Node] instance from the object name.""" - obj = get_object(name) - if not isinstance(obj, Module | Class | Function | Attribute): - raise NotImplementedError - return _get_node(obj, None) - - -def _get_node(obj: Module | Class | Function | Attribute, parent: Node | None) -> Node: - node = Node(obj.name, obj, parent) - if isinstance(obj, Module | Class): - node.members = list(_iter_members(node, obj)) - return node - - -def _iter_members(node: Node, obj: Module | Class) -> Iterator[Node]: - for member in obj.attributes + obj.classes + obj.functions: - yield _get_node(member, node) +# """Node class represents Markdown and HTML structure.""" +# from __future__ import annotations + +# from dataclasses import dataclass, field +# from typing import TYPE_CHECKING + +# from mkapi.importlib import get_object +# from mkapi.objects import Class, Function, Module +# from mkapi.utils import get_by_name + +# if TYPE_CHECKING: +# from collections.abc import Iterator + + +# @dataclass +# class Node: +# """Node class.""" + +# name: str +# object: Module | Class | Function # noqa: A003 +# members: list[Node] = field(default_factory=list, init=False) +# parent: Node | None = None +# html: str = field(init=False) + +# def __repr__(self) -> str: +# class_name = self.__class__.__name__ +# return f"{class_name}({self.name!r}, members={len(self)})" + +# def __iter__(self) -> Iterator[Node]: +# yield self +# for member in self.members: +# yield from member + +# def __len__(self) -> int: +# return len(self.members) + +# def __contains__(self, name: str) -> bool: +# if self.members: +# return any(member.object.name == name for member in self.members) +# return False + +# def get(self, name: str) -> Node | None: # noqa: D102 +# return get_by_name(self.members, name) if self.members else None + +# def get_kind(self) -> str: +# """Returns kind of self.""" +# raise NotImplementedError + +# def get_markdown(self, level: int, filters: list[str]) -> str: +# """Returns a Markdown source for docstring of self.""" +# markdowns = [] +# for node in self: +# markdowns.append(f"{node.id}\n\n") # noqa: PERF401 +# return "\n\n\n\n".join(markdowns) + +# def convert_html(self, html: str, level: int, filters: list[str]) -> str: +# htmls = html.split("") +# for node, html in zip(self.walk(), htmls, strict=False): +# node.html = html.strip() +# return "a" + +# @property +# def id(self): +# if isinstance(self.object, Module): +# return self.object.name +# return self.object.fullname + + +# def get_node(name: str) -> Node: +# """Return a [Node] instance from the object name.""" +# obj = get_object(name) +# if not isinstance(obj, Module | Class | Function): +# raise NotImplementedError +# return _get_node(obj, None) + + +# def _get_node(obj: Module | Class | Function, parent: Node | None) -> Node: +# node = Node(obj.name, obj, parent) +# if isinstance(obj, Module | Class): +# node.members = list(_iter_members(node, obj)) +# return node + + +# def _iter_members(node: Node, obj: Module | Class) -> Iterator[Node]: +# for member in obj.classes + obj.functions: +# yield _get_node(member, node) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index bde0754b..565716b6 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -239,17 +239,6 @@ def _create_empty_module() -> Module: return Module(None, name, doc, [], [], None, None) -def iter_objects(obj: Module | Class | Function) -> Iterator[Module | Class | Function]: - """Yield [Class] or [Function] instances.""" - yield obj - for cls in obj.classes: - if isinstance(obj, Module) or cls.module is obj.module: - yield from iter_objects(cls) - for func in obj.functions: - if isinstance(obj, Module) or func.module is obj.module: - yield from iter_objects(func) - - def merge_parameters(obj: Class | Function) -> None: """Merge parameters.""" section = get_by_type(obj.doc.sections, Parameters) @@ -322,6 +311,17 @@ def merge_items(obj: Module | Class | Function) -> None: _merge_items(obj_) +def iter_objects(obj: Module | Class | Function) -> Iterator[Module | Class | Function]: + """Yield [Class] or [Function] instances.""" + yield obj + for cls in obj.classes: + if isinstance(obj, Module) or cls.module is obj.module: + yield from iter_objects(cls) + for func in obj.functions: + if isinstance(obj, Module) or func.module is obj.module: + yield from iter_objects(func) + + def iter_items(obj: Module | Class | Function) -> Iterator[Item]: """Yield [Item] instances.""" for obj_ in iter_objects(obj): diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index ae9a1e44..e0e5d517 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -1,19 +1,79 @@ """Page class that works with other converter.""" from __future__ import annotations +import os import re from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING -# from mkapi.converter import convert_html, convert_object -from mkapi.link import resolve_link -from mkapi.nodes import get_node +from mkapi import renderers +from mkapi.importlib import get_object, iter_texts, iter_types +from mkapi.objects import Class, Function, Module from mkapi.utils import split_filters, update_filters +# from mkapi.converter import convert_html, convert_object + if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Iterator + + from mkapi.items import Text, Type + + +@dataclass(repr=False) +class Page: + """Page class works with [MkAPIPlugin](mkapi.plugins.MkAPIPlugin). + + Args: + source: Markdown source. + abs_src_path: Absolute source path of Markdown. + abs_api_paths: A list of API paths. - from mkapi.nodes import Node + Attributes: + markdown: Converted Markdown including API documentation. + nodes: A list of Node instances. + """ + + source: str + abs_src_path: str + abs_api_paths: list[str] + filters: list[list[str]] = field(default_factory=list) + objects: list[Module | Class | Function] = field(default_factory=list, init=False) + levels: list[int] = field(default_factory=list, init=False) + + def convert_markdown(self) -> str: # noqa: D102 + index, markdowns = 0, [] + for name, level, filters in _iter_markdown(self.source): + if level == -1: + markdowns.append(name) + else: + markdown = self._callback_markdown(name, level, filters) + markdowns.append(_object_markdown(markdown, index)) + index += 1 + markdown = "\n\n".join(markdowns) + return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) + + def _callback_markdown(self, name: str, level: int, filters: list[str]) -> str: + obj = get_object(name) + if not isinstance(obj, Module | Class | Function): + raise NotImplementedError + self.objects.append(obj) + self.levels.append(level) + self.filters.append(filters) + return get_markdown(obj) + + def convert_html(self, html: str) -> str: # noqa: D102 + def replace(match: re.Match) -> str: + index, html = match.groups() + return self._callback_html(int(index), html) + + return re.sub(NODE_PATTERN, replace, html) + + def _callback_html(self, index: int, html: str) -> str: + obj = self.objects[index] + level = self.levels[index] + filters = self.filters[index] + return convert_html(obj, html, level, filters) OBJECT_PATTERN = re.compile(r"^(#*) *?::: (.+?)$", re.MULTILINE) @@ -35,73 +95,101 @@ def _iter_markdown(source: str) -> Iterator[tuple[str, int, list[str]]]: yield markdown, -1, [] -def convert_markdown( - source: str, - callback: Callable[[str, int, list[str]], str], -) -> str: - """Return a converted markdown.""" - index, texts = 0, [] - for name, level, filters in _iter_markdown(source): - if level == -1: - texts.append(name) - else: - text = callback(name, level, filters) - texts.append(f"\n{text}\n") - index += 1 - return "\n\n".join(texts) - - -pattern = r"\n(.*?)\n" -NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) +def _object_markdown(markdown: str, index: int) -> str: + return f"\n\n{markdown}\n\n" -def convert_html(source: str, callback: Callable[[int, str], str]) -> str: - """Return modified HTML.""" +pattern = r"(.*?)" +NODE_PATTERN = re.compile(pattern, re.MULTILINE | re.DOTALL) - def replace(match: re.Match) -> str: - index, html = match.groups() - return callback(int(index), html) - return re.sub(NODE_PATTERN, replace, source) +def _iter_type_text(obj: Module | Class | Function) -> Iterator[Type | Text]: + for type_ in iter_types(obj): + if type_.markdown: + yield type_ + for text in iter_texts(obj): + if text.markdown: + yield text -@dataclass(repr=False) -class Page: - """Page class works with [MkAPIPlugin](mkapi.plugins.mkdocs.MkAPIPlugin). +def get_markdown(obj: Module | Class | Function) -> str: + """Returns a Markdown source.""" + markdowns = [] + for type_text in _iter_type_text(obj): + markdowns.append(type_text.markdown) # noqa: PERF401 + return "\n\n\n\n".join(markdowns) - Args: - source: Markdown source. - abs_src_path: Absolute source path of Markdown. - abs_api_paths: A list of API paths. - Attributes: - markdown: Converted Markdown including API documentation. - nodes: A list of Node instances. - """ +def convert_html( + obj: Module | Class | Function, + html: str, + level: int, + filters: list[str], +) -> str: + """Convert HTML input.""" + htmls = html.split("") + for type_text, html in zip(_iter_type_text(obj), htmls, strict=True): + type_text.html = html.strip() + # return renderers.render(obj, level, filters) + return html - source: str - abs_src_path: str - abs_api_paths: list[str] - nodes: list[Node] = field(default_factory=list) - levels: list[int] = field(default_factory=list) - filters: list[list[str]] = field(default_factory=list) - def _callback_markdown(self, name: str, level: int, filters: list[str]) -> str: - node = get_node(name) - self.nodes.append(node) - self.levels.append(level) - self.filters.append(filters) - return node.get_markdown(level, filters) +LINK_PATTERN = re.compile(r"\[(\S+?)\]\[(\S+?)\]") - def convert_markdown(self) -> str: # noqa: D102 - markdown = convert_markdown(self.source, self._callback_markdown) - return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) - def _callback_html(self, index: int, html: str) -> str: - node = self.nodes[index] - level = self.levels[index] - filters = self.filters[index] - return node.convert_html(html, level, filters) +def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> str: + """Reutrn resolved link. - def convert_html(self, html: str) -> str: # noqa: D102 - return convert_html(html, self._callback_html) + Args: + markdown: Markdown source. + abs_src_path: Absolute source path of Markdown. + abs_api_paths: List of API paths. + + Examples: + >>> abs_src_path = '/src/examples/example.md' + >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] + >>> resolve_link('[abc][b.c.d]', abs_src_path, abs_api_paths) + '[abc](../../api/b.c#b.c.d)' + >>> resolve_link('[abc][__mkapi__.b.c.d]', abs_src_path, abs_api_paths) + '[abc](../../api/b.c#b.c.d)' + >>> resolve_link('list[[abc][__mkapi__.b.c.d]]', abs_src_path, abs_api_paths) + 'list[[abc](../../api/b.c#b.c.d)]' + """ + + def replace(match: re.Match) -> str: + name, href = match.groups() + if href.startswith("!__mkapi__."): # Just for MkAPI documentation. + href = href[11:] + return f"[{name}]({href})" + from_mkapi = False + if href.startswith("__mkapi__."): + href = href[10:] + from_mkapi = True + + if href := _resolve_href(href, abs_src_path, abs_api_paths): + # print(f"[{name}]({href})") + return f"[{name}]({href})" + return name if from_mkapi else match.group() + + return re.sub(LINK_PATTERN, replace, markdown) + + +def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: + if not name: + return "" + abs_api_path = _match_last(name, abs_api_paths) + if not abs_api_path: + return "" + relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) + relpath = relpath.replace("\\", "/") + return f"{relpath}#{name}" + + +# TODO: match longest. +def _match_last(name: str, abs_api_paths: list[str]) -> str: + match = "" + for abs_api_path in abs_api_paths: + _, path = os.path.split(abs_api_path) + if name.startswith(path[:-3]): + match = abs_api_path + return match diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 7d0822e7..91674111 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -64,7 +64,7 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: return _on_config_plugin(config, self) def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: - """Collect plugin CSS/JavaScript and appends them to `files`.""" + """Collect plugin CSS/JavaScript and append them to `files`.""" root = Path(mkapi.__file__).parent / "themes" docs_dir = config.docs_dir config.docs_dir = root.as_posix() @@ -96,7 +96,7 @@ def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: return files def on_page_markdown(self, markdown: str, page: MkDocsPage, **kwargs) -> str: - """Convert Markdown source to intermidiate version.""" + """Convert Markdown source to intermediate version.""" # clean_page_title(page) abs_src_path = page.file.abs_src_path abs_api_paths = self.config.abs_api_paths @@ -110,7 +110,6 @@ def on_page_content(self, html: str, page: MkDocsPage, **kwargs) -> str: # if page.title: # page.title = re.sub(r"<.*?>", "", str(page.title)) # type: ignore mkapi_page: MkAPIPage = self.config.pages[page.file.abs_src_path] - return mkapi_page.convert_html(html) def on_page_context( @@ -128,9 +127,8 @@ def on_page_context( # clear_prefix(page.toc, 2) else: mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - for level, id_ in mkapi_page.headings: - pass - # clear_prefix(page.toc, level, id_) + # for level, id_ in mkapi_page.headings: + # clear_prefix(page.toc, level, id_) return context def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): @@ -290,7 +288,7 @@ def callback(name: str, depth: int, ispackage) -> dict[str, str]: return nav, abs_api_paths -def _create_page(name: str, path: Path, filters: list[str] | None = None) -> None: +def _create_page(name: str, path: Path, filters: list[str]) -> None: with path.open("w") as file: file.write(renderers.render_module(name, filters)) diff --git a/src/mkapi/renderers.py b/src/mkapi/renderers.py index 819f7679..da92bfcd 100644 --- a/src/mkapi/renderers.py +++ b/src/mkapi/renderers.py @@ -1,12 +1,19 @@ """Renderer class.""" +from __future__ import annotations + import os -from dataclasses import dataclass, field +import re +from html.parser import HTMLParser from pathlib import Path +from typing import TYPE_CHECKING from jinja2 import Environment, FileSystemLoader, Template, select_autoescape import mkapi -from mkapi.objects import Module, load_module +from mkapi.importlib import load_module + +if TYPE_CHECKING: + from mkapi.objects import Class, Function, Module templates: dict[str, Template] = {} @@ -21,7 +28,7 @@ def load_templates(path: Path | None = None) -> None: templates[Path(name).stem] = env.get_template(name) -def render_module(name: str, filters: list[str] | None = None) -> str: +def render_module(name: str, filters: list[str]) -> str: """Return a rendered Markdown for Module. Args: @@ -45,106 +52,45 @@ def render_module(name: str, filters: list[str] | None = None) -> str: # module_filter=module_filter, # object_filter=object_filter, ) - # return f"{module}: {id(module)}" -@dataclass -class Renderer: - """Render [Object] instance recursively to create API documentation. +def render(obj: Module | Class | Function, level: int, filters: list[str]) -> str: + """Return a rendered HTML for Node. - Attributes: - templates: Jinja template dictionary. + Args: + obj: Object instance. + level: Heading level. + filters: Filters. """ + return "" + obj_str = render_object(obj, filters=filters) + doc_str = render_docstring(obj.doc, filters=filters) + members = [] + for member in obj.classes + obj.functions: + member_str = render(member, level + 1, filters) + members.append(member_str) + return templates["node"].render(obj=obj_str, doc=doc_str, members=members) - templates: dict[str, Template] = field(default_factory=dict, init=False) - def __post_init__(self) -> None: - path = Path(mkapi.__file__).parent / "templates" - loader = FileSystemLoader(path) - env = Environment(loader=loader, autoescape=select_autoescape(["jinja2"])) - for name in os.listdir(path): - template = env.get_template(name) - self.templates[Path(name).stem] = template - - def render_module(self, module: Module, filters: list[str] | None = None) -> str: - """Return a rendered Markdown for Module. - - Args: - module: Module instance. - filters: A list of filters. Avaiable filters: `inherit`, `strict`, - `heading`. - - Note: - This function returns Markdown instead of HTML. The returned Markdown - will be converted into HTML by MkDocs. Then the HTML is rendered into HTML - again by other functions in this module. - """ - filters = filters if filters else [] - module_filter = object_filter = "" - if filters: - object_filter = "|" + "|".join(filters) - template = self.templates["module"] - return template.render( - module=module, - module_filter=module_filter, - object_filter=object_filter, - ) - - # def render(self, node: Node, filters: list[str] | None = None) -> str: - # """Return a rendered HTML for Node. +def render_object(obj: Module | Class | Function, filters: list[str]) -> str: + """Return a rendered HTML for Object. - # Args: - # node: Node instance. - # filters: Filters. - # """ - # obj = self.render_object(node.object, filters=filters) - # docstring = self.render_docstring(node.docstring, filters=filters) - # members = [self.render(member, filters) for member in node.members] - # return self.render_node(node, obj, docstring, members) - - # def render_node( - # self, - # node: Node, - # obj: str, - # docstring: str, - # members: list[str], - # ) -> str: - # """Return a rendered HTML for Node using prerendered components. - - # Args: - # node: Node instance. - # obj: Rendered HTML for Object instance. - # docstring: Rendered HTML for Docstring instance. - # members: A list of rendered HTML for member Node instances. - # """ - # template = self.templates["node"] - # return template.render( - # node=node, - # object=obj, - # docstring=docstring, - # members=members, - # ) - - # def render_object(self, obj: Object, filters: list[str] | None = None) -> str: - # """Return a rendered HTML for Object. - - # Args: - # obj: Object instance. - # filters: Filters. - # """ - # filters = filters if filters else [] - # context = link.resolve_object(obj.html) - # level = context.get("level") - # if level: - # if obj.kind in ["module", "package"]: - # filters.append("plain") - # elif "plain" in filters: - # del filters[filters.index("plain")] - # tag = f"h{level}" - # else: - # tag = "div" - # template = self.templates["object"] - # return template.render(context, object=obj, tag=tag, filters=filters) + Args: + obj: Object instance. + filters: Filters. + """ + context = resolve_object(obj.html) + level = context.get("level") + if level: + if obj.kind in ["module", "package"]: + filters.append("plain") + elif "plain" in filters: + del filters[filters.index("plain")] + tag = f"h{level}" + else: + tag = "div" + template = self.templates["object"] + return template.render(context, object=obj, tag=tag, filters=filters) # def render_object_member( # self, @@ -207,5 +153,54 @@ def render_module(self, module: Module, filters: list[str] | None = None) -> str # return template.render(code=code, module=code.module, filters=filters) -#: Renderer instance that is used globally. -# renderer: Renderer = Renderer() +class ObjectParser(HTMLParser): # noqa: D101 + def feed(self, html: str) -> dict[str, int | str]: # noqa: D102 + self.context = {"href": [], "heading_id": ""} + super().feed(html) + href = self.context["href"] + if len(href) == 2: + prefix_url, name_url = href + elif len(href) == 1: + prefix_url, name_url = "", href[0] + else: + prefix_url, name_url = "", "" + self.context["prefix_url"] = prefix_url + self.context["name_url"] = name_url + del self.context["href"] + return self.context + + def handle_starttag(self, tag: str, attrs: list) -> None: # noqa: D102 + context = self.context + if tag == "p": + context["level"] = 0 + elif re.match(r"h[1-6]", tag): + context["level"] = int(tag[1:]) + for attr in attrs: + if attr[0] == "id": + self.context["heading_id"] = attr[1] + elif tag == "a": + for attr in attrs: + if attr[0] == "href": + href = attr[1] + if href.startswith("./"): + href = href[2:] + self.context["href"].append(href) + + +parser = ObjectParser() + + +def resolve_object(html: str) -> dict[str, int | str]: + """Reutrns an object context dictionary. + + Args: + html: HTML source. + + Examples: + >>> resolve_object("

    pn

    ") + {'heading_id': '', 'level': 0, 'prefix_url': 'a', 'name_url': 'b'} + >>> resolve_object("

    pn

    ") + {'heading_id': 'i', 'level': 2, 'prefix_url': 'a', 'name_url': 'b'} + """ + parser.reset() + return parser.feed(html) diff --git a/src/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 index 745e0221..2c246579 100644 --- a/src/mkapi/templates/module.jinja2 +++ b/src/mkapi/templates/module.jinja2 @@ -2,12 +2,6 @@ {{ module.kind }} -## Attributes - -{% for attr in module.attributes -%} -### ::: {{ attr.fullname }} -{% endfor -%} - ## Classes {% for cls in module.classes -%} diff --git a/tests/test_importlib.py b/tests/test_importlib.py index 65b9480f..f03592f4 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -89,6 +89,14 @@ def test_get_fullname(): assert get_fullname(module, "jinja2.Template") == name +def test_get_fullname_self(): + module = load_module("mkapi.objects") + assert module + assert get_fullname(module, "Object") == "mkapi.objects.Object" + assert get_fullname(module, "mkapi.objects") == "mkapi.objects" + assert get_fullname(module, "mkapi.objects.Object") == "mkapi.objects.Object" + + def test_iter_base_classes(): cls = get_object("mkapi.plugins.MkAPIPlugin") assert isinstance(cls, Class) @@ -179,6 +187,7 @@ def test_set_markdown_objects(): x = [t.markdown for t in iter_types(module)] assert "[Class][__mkapi__.mkapi.objects.Class] | None" in x assert "list[[Raise][__mkapi__.mkapi.items.Raise]]" in x + assert "[mkapi][__mkapi__.mkapi].[objects][__mkapi__.mkapi.objects]" in x def test_set_markdown_plugins(): diff --git a/tests/test_link.py b/tests/test_link.py deleted file mode 100644 index 7d947d86..00000000 --- a/tests/test_link.py +++ /dev/null @@ -1,38 +0,0 @@ -# import typing - -# from mkapi.link import get_link, resolve_link, resolve_object - - -# def test_get_link_private(): -# class A: -# def func(self): -# pass - -# def _private(self): -# pass - -# q = "test_get_link_private..A" -# m = "test_link" -# assert get_link(A) == f"[{q}](!{m}.{q})" -# assert get_link(A, include_module=True) == f"[{m}.{q}](!{m}.{q})" -# assert get_link(A.func) == f"[{q}.func](!{m}.{q}.func)" # type: ignore -# assert get_link(A._private) == f"{q}._private" # type: ignore # noqa: SLF001 - - -# def test_get_link_typing(): -# assert get_link(typing.Self) == "[Self](!typing.Self)" - - -# def test_resolve_link(): -# assert resolve_link("[A](!!a)", "", []) == "[A](a)" -# assert resolve_link("[A](!a)", "", []) == "A" -# assert resolve_link("[A](a)", "", []) == "[A](a)" - - -# def test_resolve_object(): -# html = "

    p

    " -# context = resolve_object(html) -# assert context == {"heading_id": "", "level": 0, "prefix_url": "", "name_url": "a"} -# html = "

    pn

    " -# context = resolve_object(html) -# assert context == {"heading_id": "", "level": 0, "prefix_url": "a", "name_url": "b"} diff --git a/tests/test_nodes.py b/tests/test_nodes.py deleted file mode 100644 index 9490ccdb..00000000 --- a/tests/test_nodes.py +++ /dev/null @@ -1,33 +0,0 @@ -from mkapi.nodes import get_node -from mkapi.objects import load_module, objects - - -def f(): - pass - - -class A: - x: int - - -def test_node(): - print(A.__name__, A.__module__) - A.x = 1 - print(A.x.__name__, A.x.__module__) - assert 0 - # node = load_module("examples.styles.example_google") - # print(objects) - # node = get_node("examples.styles.example_google") - # for m in node.walk(): - # print(m) - # print(node.object.text) - # assert 0 - - -# def test_property(): -# module = load_module("mkapi.objects") -# assert module -# assert module.id == "mkapi.objects" -# f = module.get_function("get_object") -# assert f -# assert f.id == "mkapi.objects.get_object" diff --git a/tests/test_pages.py b/tests/test_pages.py index 82529b56..a234fd0b 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -1,6 +1,7 @@ from markdown import Markdown -from mkapi.pages import _iter_markdown, convert_html, convert_markdown +from mkapi.objects import Class, iter_types +from mkapi.pages import Page, _iter_markdown source = """ # Title @@ -28,68 +29,36 @@ def callback_markdown(name, level, filters): def test_convert_markdown(): - x = convert_markdown(source, callback_markdown) - assert "# Title\n\n" in x - assert "\n[2](a|b)\n\n\n" in x - assert "\n\ntext\n\n" in x - assert "\n[3]()\n\n\n" in x - assert "\n[0](m)\n\n\n" in x - - -def callback_html(index, html): - return f"{index}{html[:10]}" + abs_src_path = "/examples/docs/tutorial/index.md" + abs_api_paths = [ + "/examples/docs/api/mkapi.md", + "/examples/docs/api/mkapi.items.md", + ] + page = Page("::: mkapi.items.Parameters", abs_src_path, abs_api_paths) + x = page.convert_markdown() + assert "" in x + assert "" in x + assert "[Section](../api/mkapi.items.md#mkapi.items.Section)" in x + assert "[Item](../api/mkapi.items.md#mkapi.items.Item) | None" in x + assert "[mkapi](../api/mkapi.md#mkapi).[items]" in x + assert "list[[Parameter](../api/mkapi.items.md" in x + page = Page("::: mkapi.items.Parameters", "", []) + x = page.convert_markdown() + assert "../api/mkapi.items" not in x def test_convert_html(): - markdown = convert_markdown(source, callback_markdown) - converter = Markdown() - html = converter.convert(markdown) - assert "

    Title

    \n" in html - assert "\n" in html - assert '

    2

    ' in html - assert '

    3

    ' in html - assert '

    0

    ' in html - html = convert_html(html, callback_html) - assert "

    Title

    \n" in html - assert "0

    \n\n" in html - assert "1

    \n\n" in html - assert "2

    \n\n" in html - - -def test_page(): abs_src_path = "/examples/docs/tutorial/index.md" abs_api_paths = [ - "/examples/docs/api/a.o.md", - "/examples/docs/api/a.s.md", + "/examples/docs/api/mkapi.md", + "/examples/docs/api/mkapi.items.md", ] - # page = Page(source, abs_src_path, abs_api_paths) - # m = page.convert_markdown() - # print(m) - # assert m.startswith("# Title\n") - # assert "" in m - # assert "## [a.o](../api/a.o.md#mkapi.core).[base]" in m - # assert "" in m - # assert "[mkapi.core.base](../api/mkapi.core.base.md#mkapi.core.base)." in m - # assert "[Base](../api/mkapi.core.base.md#mkapi.core.base.Base)" in m - # assert "\n###" in m - # assert "\n####" in m - # assert m.endswith("end") - - # converter = Markdown() - # h = page.convert_html(converter.convert(m)) - # assert "

    Title

    " in h - # print("-" * 40) - # print(h) - # assert 0 - # assert '
    ' in h - # assert 'MKAPI.CORE' in h - # assert 'BASE' in h - # assert 'Base' in h - # assert '
    ' in h - # assert '
    ' in h - # assert 'mkapi.core.base' in h - # assert 'Base' in h - # assert 'Attributes' in h - # assert 'Methods' in h - # assert 'Classes' in h - # assert "

    end

    " in h + page = Page("::: mkapi.items.Parameters", abs_src_path, abs_api_paths) + markdown = page.convert_markdown() + converter = Markdown() + html = converter.convert(markdown) + page.convert_html(html) + obj = page.objects[0] + assert isinstance(obj, Class) + x = "\n".join(x.html for x in iter_types(obj)) + assert '

    Item' in x From b1942d9955b70f6a0952101cb449a6d366b28d30 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 15 Jan 2024 13:46:09 +0900 Subject: [PATCH 096/148] Callable class --- src/mkapi/importlib.py | 29 +++++-- src/mkapi/inspect.py | 54 ++++++++++++ src/mkapi/items.py | 4 + src/mkapi/objects.py | 134 ++++++++++-------------------- src/mkapi/pages.py | 4 +- src/mkapi/templates/module.jinja2 | 13 +-- tests/test_importlib.py | 21 +++-- tests/test_items.py | 4 +- tests/test_objects.py | 42 +++++----- 9 files changed, 163 insertions(+), 142 deletions(-) create mode 100644 src/mkapi/inspect.py diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 3744ceb6..82e05d6f 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -20,13 +20,17 @@ merge_items, objects, ) -from mkapi.utils import del_by_name, get_module_path, iter_parent_modulenames +from mkapi.utils import ( + del_by_name, + get_by_name, + get_module_path, + iter_parent_modulenames, +) if TYPE_CHECKING: from collections.abc import Iterator - from inspect import _ParameterKind - from mkapi.objects import Attribute, Function + from mkapi.objects import Function, Import modules: dict[str, Module | None] = {} @@ -68,15 +72,26 @@ def get_object(fullname: str) -> Module | Class | Function | None: return None +def get_member(module: Module, name: str) -> Import | Class | Function | None: + """Return a member instance by the name.""" + if obj := get_by_name(module.imports, name): + return obj + if obj := get_by_name(module.classes, name): + return obj + if obj := get_by_name(module.functions, name): + return obj + return None + + def get_fullname(module: Module, name: str) -> str | None: """Return the fullname of an object in the module.""" - if obj := module.get_member(name): + if obj := get_member(module, name): return obj.fullname if "." in name: name_, attr = name.rsplit(".", maxsplit=1) - if attr_ := module.get_attribute(name_): + if attr_ := get_by_name(module.attributes, name_): return f"{module.name}.{attr_.name}" - if import_ := module.get_import(name_): # noqa: SIM102 + if import_ := get_by_name(module.imports, name_): # noqa: SIM102 if module_ := load_module(import_.fullname): # noqa: SIM102 if fullname := get_fullname(module_, attr): return fullname @@ -97,7 +112,7 @@ def _postprocess(obj: Module | Class) -> None: def _postprocess_class(cls: Class) -> None: inherit_base_classes(cls) - if init := cls.get_function("__init__"): + if init := get_by_name(cls.functions, "__init__"): cls.parameters = init.parameters cls.raises = init.raises cls.doc = mkapi.docstrings.merge(cls.doc, init.doc) diff --git a/src/mkapi/inspect.py b/src/mkapi/inspect.py new file mode 100644 index 00000000..957a1550 --- /dev/null +++ b/src/mkapi/inspect.py @@ -0,0 +1,54 @@ +"""Inspect module.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mkapi.objects import Class, Function, Module +from mkapi.utils import get_by_name + +if TYPE_CHECKING: + from mkapi.items import Attribute, Import, Parameter, Raise + +type Object = Module | Class | Function + + +def get_source(obj: Object, maxline: int | None = None) -> str | None: + """Return the source code of an object.""" + if isinstance(obj, Module): + if obj.source: + return "\n".join(obj.source.split("\n")[:maxline]) + return None + if (module := obj.module) and (source := module.source): + start, stop = obj.node.lineno - 1, obj.node.end_lineno + return "\n".join(source.split("\n")[start:stop][:maxline]) + return None + + +def get_class(obj: Object, name: str) -> Class | None: + """Return a [Class] instance by the name.""" + return get_by_name(obj.classes, name) + + +def get_function(obj: Object, name: str) -> Function | None: + """Return a [Function] instance by the name.""" + return get_by_name(obj.functions, name) + + +def get_attribute(obj: Module | Class, name: str) -> Attribute | None: + """Return an [Attribute] instance by the name.""" + return get_by_name(obj.attributes, name) + + +def get_parameter(obj: Class | Function, name: str) -> Parameter | None: + """Return a [Parameter] instance by the name.""" + return get_by_name(obj.parameters, name) + + +def get_raise(obj: Class | Function, name: str) -> Raise | None: + """Return a [Raise] instance by the name.""" + return get_by_name(obj.raises, name) + + +def get_import(obj: Module, name: str) -> Import | None: + """Return an [Import] instance by the name.""" + return get_by_name(obj.imports, name) diff --git a/src/mkapi/items.py b/src/mkapi/items.py index 87f33a49..acf62554 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -3,6 +3,7 @@ import ast from dataclasses import dataclass, field +from enum import Enum from typing import TYPE_CHECKING import mkapi.ast @@ -18,12 +19,15 @@ from collections.abc import Iterable, Iterator, Sequence from inspect import _ParameterKind +TypeKind = Enum("TypeKind", ["OBJECT", "REFERENCE"]) + @dataclass class Type: """Type class.""" expr: ast.expr | None = None + kind: TypeKind = TypeKind.REFERENCE markdown: str = field(default="", init=False) html: str = field(default="", init=False) diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 565716b6..b5d390a7 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -17,6 +17,7 @@ Returns, Text, Type, + TypeKind, create_attributes, create_parameters, create_raises, @@ -36,76 +37,64 @@ from mkapi.items import Attribute, Base, Import, Parameter, Raise, Return +objects: dict[str, Module | Class | Function | None] = {} + + @dataclass class Object: - """Object class for class or function.""" + """Object class for module, class, or function.""" - node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef + node: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef name: str - module: Module - parent: Object | None doc: Docstring - classes: list[Class] - functions: list[Function] - parameters: list[Parameter] - raises: list[Raise] + classes: list[Class] = field(default_factory=list, init=False) + functions: list[Function] = field(default_factory=list, init=False) qualname: str = field(init=False) fullname: str = field(init=False) def __post_init__(self) -> None: - if self.parent: - self.qualname = f"{self.parent.qualname}.{self.name}" - else: - self.qualname = self.name - self.fullname = f"{self.module.name}.{self.qualname}" self.doc.name = self.fullname expr = ast.parse(self.fullname).body[0] if isinstance(expr, ast.Expr): self.doc.type.expr = expr.value + self.doc.type.kind = TypeKind.OBJECT objects[self.fullname] = self # type:ignore def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name})" - def get_source(self, maxline: int | None = None) -> str | None: - """Return the source code segment.""" - if (module := self.module) and (source := module.source): - start, stop = self.node.lineno - 1, self.node.end_lineno - return "\n".join(source.split("\n")[start:stop][:maxline]) - return None - - def get_class(self, name: str) -> Class | None: - """Return a [Class] instance by the name.""" - return get_by_name(self.classes, name) - def get_function(self, name: str) -> Function | None: - """Return a [Function] instance by the name.""" - return get_by_name(self.functions, name) - - def get_parameter(self, name: str) -> Parameter | None: - """Return a [Parameter] instance by the name.""" - return get_by_name(self.parameters, name) - - def get_raise(self, name: str) -> Raise | None: - """Return a [Raise] instance by the name.""" - return get_by_name(self.raises, name) +@dataclass(repr=False) +class Callable(Object): + """Callable class for class or function.""" + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef + module: Module = field(kw_only=True) + parent: Callable | None = field(kw_only=True) -objects: dict[str, Class | Function | None] = {} + def __post_init__(self) -> None: + if self.parent: + self.qualname = f"{self.parent.qualname}.{self.name}" + else: + self.qualname = self.name + self.fullname = f"{self.module.name}.{self.qualname}" + super().__post_init__() @dataclass(repr=False) -class Function(Object): +class Function(Callable): """Function class.""" node: ast.FunctionDef | ast.AsyncFunctionDef + parameters: list[Parameter] returns: list[Return] + raises: list[Raise] def create_function( node: ast.FunctionDef | ast.AsyncFunctionDef, module: Module | None = None, - parent: Object | None = None, + parent: Callable | None = None, ) -> Function: """Return a [Function] instance.""" module = module or _create_empty_module() @@ -113,8 +102,8 @@ def create_function( parameters = list(iter_parameters(node)) raises = list(iter_raises(node)) returns = list(iter_returns(node)) - args = ([], [], parameters, raises, returns) - func = Function(node, node.name, module, parent, doc, *args) + args = (parameters, returns, raises) + func = Function(node, node.name, doc, *args, module=module, parent=parent) for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.ClassDef): func.classes.append(create_class(child, module, func)) @@ -124,12 +113,14 @@ def create_function( @dataclass(repr=False) -class Class(Object): +class Class(Callable): """Class class.""" node: ast.ClassDef - attributes: list[Attribute] bases: list[Base] + attributes: list[Attribute] + parameters: list[Parameter] = field(default_factory=list, init=False) + raises: list[Raise] = field(default_factory=list, init=False) def get_attribute(self, name: str) -> Attribute | None: """Return an [Attribute] instance by the name.""" @@ -139,78 +130,37 @@ def get_attribute(self, name: str) -> Attribute | None: def create_class( node: ast.ClassDef, module: Module | None = None, - parent: Object | None = None, + parent: Callable | None = None, ) -> Class: """Return a [Class] instance.""" name = node.name module = module or _create_empty_module() doc = docstrings.parse(ast.get_docstring(node)) - attributes = list(iter_attributes(node)) bases = list(iter_bases(node)) - args = ([], [], [], [], attributes, bases) - cls = Class(node, name, module, parent, doc, *args) + attributes = list(iter_attributes(node)) + cls = Class(node, name, doc, bases, attributes, module=module, parent=parent) for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.ClassDef): cls.classes.append(create_class(child, module, cls)) elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): # noqa: SIM102 - if not cls.get_attribute(child.name): # for property + if not get_by_name(attributes, child.name): # for property cls.functions.append(create_function(child, module, cls)) return cls -@dataclass -class Module: +@dataclass(repr=False) +class Module(Object): """Module class.""" - node: ast.Module | None - name: str - doc: Docstring + node: ast.Module imports: list[Import] attributes: list[Attribute] - classes: list[Class] = field(default_factory=list, init=False) - functions: list[Function] = field(default_factory=list, init=False) source: str | None = None kind: str | None = None def __post_init__(self) -> None: - expr = ast.parse(self.name).body[0] - if isinstance(expr, ast.Expr): - self.doc.type.expr = expr.value - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.name})" - - def get_import(self, name: str) -> Import | None: - """Return an [Import] instance by the name.""" - return get_by_name(self.imports, name) - - def get_attribute(self, name: str) -> Attribute | None: - """Return an [Attribute] instance by the name.""" - return get_by_name(self.attributes, name) - - def get_class(self, name: str) -> Class | None: - """Return an [Class] instance by the name.""" - return get_by_name(self.classes, name) - - def get_function(self, name: str) -> Function | None: - """Return an [Function] instance by the name.""" - return get_by_name(self.functions, name) - - def get_source(self, maxline: int | None = None) -> str | None: - """Return the source of the module.""" - if not self.source: - return None - return "\n".join(self.source.split("\n")[:maxline]) - - def get_member(self, name: str) -> Import | Class | Function | None: - """Return a member instance by the name.""" - if obj := self.get_import(name): - return obj - if obj := self.get_class(name): - return obj - if obj := self.get_function(name): - return obj - return None + self.fullname = self.qualname = self.name + super().__post_init__() def create_module(node: ast.Module, name: str = "__mkapi__") -> Module: @@ -236,7 +186,7 @@ def create_module(node: ast.Module, name: str = "__mkapi__") -> Module: def _create_empty_module() -> Module: name = "__mkapi__" doc = Docstring("", Type(None), Text(None), []) - return Module(None, name, doc, [], [], None, None) + return Module(ast.Module(), name, doc, [], [], None, None) def merge_parameters(obj: Class | Function) -> None: diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index e0e5d517..701d0625 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -22,7 +22,7 @@ @dataclass(repr=False) class Page: - """Page class works with [MkAPIPlugin](mkapi.plugins.MkAPIPlugin). + """Page class works with [MkAPIPlugin][mkapi.plugins.MkAPIPlugin]. Args: source: Markdown source. @@ -113,7 +113,7 @@ def _iter_type_text(obj: Module | Class | Function) -> Iterator[Type | Text]: def get_markdown(obj: Module | Class | Function) -> str: - """Returns a Markdown source.""" + """Return a Markdown source.""" markdowns = [] for type_text in _iter_type_text(obj): markdowns.append(type_text.markdown) # noqa: PERF401 diff --git a/src/mkapi/templates/module.jinja2 b/src/mkapi/templates/module.jinja2 index 2c246579..509eca70 100644 --- a/src/mkapi/templates/module.jinja2 +++ b/src/mkapi/templates/module.jinja2 @@ -2,14 +2,9 @@ {{ module.kind }} -## Classes - -{% for cls in module.classes -%} -### ::: {{ cls.fullname }} +{% for cls in module.classes %} +## ::: {{ cls.fullname }} {% endfor -%} - -## Functions - -{% for func in module.functions -%} -### ::: {{ func.fullname }} +{% for func in module.functions %} +## ::: {{ func.fullname }} {% endfor -%} \ No newline at end of file diff --git a/tests/test_importlib.py b/tests/test_importlib.py index f03592f4..c84b407b 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -3,6 +3,7 @@ import mkapi.ast import mkapi.importlib +import mkapi.inspect import mkapi.objects from mkapi.importlib import ( LINK_PATTERN, @@ -30,13 +31,13 @@ def test_load_module_source(): assert "class File" in module.source module = load_module("mkapi.plugins") assert module - cls = module.get_class("MkAPIConfig") + cls = get_by_name(module.classes, "MkAPIConfig") assert cls assert cls.module is module - src = cls.get_source() + src = mkapi.inspect.get_source(cls) assert src assert src.startswith("class MkAPIConfig") - src = module.get_source() + src = mkapi.inspect.get_source(module) assert src assert "MkAPIPlugin" in src @@ -55,13 +56,13 @@ def test_get_object(): mkapi.objects.objects.clear() module = load_module("mkapi.objects") c = get_object("mkapi.objects.Object") - f = get_object("mkapi.objects.Module.get_class") + f = get_object("mkapi.objects.Module.__post_init__") assert isinstance(c, Class) assert c.module is module assert isinstance(f, Function) assert f.module is module c2 = get_object("mkapi.objects.Object") - f2 = get_object("mkapi.objects.Module.get_class") + f2 = get_object("mkapi.objects.Module.__post_init__") assert c is c2 assert f is f2 m1 = load_module("mkdocs.structure.files") @@ -102,14 +103,14 @@ def test_iter_base_classes(): assert isinstance(cls, Class) assert cls.qualname == "MkAPIPlugin" assert cls.fullname == "mkapi.plugins.MkAPIPlugin" - func = cls.get_function("on_config") + func = get_by_name(cls.functions, "on_config") assert func assert func.qualname == "MkAPIPlugin.on_config" assert func.fullname == "mkapi.plugins.MkAPIPlugin.on_config" base = next(iter_base_classes(cls)) assert base.name == "BasePlugin" assert base.fullname == "mkdocs.plugins.BasePlugin" - func = base.get_function("on_config") + func = get_by_name(base.functions, "on_config") assert func assert func.qualname == "BasePlugin.on_config" assert func.fullname == "mkdocs.plugins.BasePlugin.on_config" @@ -174,7 +175,7 @@ def f(m: re.Match) -> str: def test_iter_types(): module = load_module("mkapi.plugins") assert module - cls = module.get_class("MkAPIConfig") + cls = get_by_name(module.classes, "MkAPIConfig") assert cls types = [ast.unparse(x.expr) for x in iter_types(module)] # type: ignore assert "BasePlugin[MkAPIConfig]" in types @@ -185,7 +186,9 @@ def test_set_markdown_objects(): module = load_module("mkapi.objects") assert module x = [t.markdown for t in iter_types(module)] - assert "[Class][__mkapi__.mkapi.objects.Class] | None" in x + for z in x: + print(z) + assert "[Class][__mkapi__.mkapi.objects.Class]" in x assert "list[[Raise][__mkapi__.mkapi.items.Raise]]" in x assert "[mkapi][__mkapi__.mkapi].[objects][__mkapi__.mkapi.objects]" in x diff --git a/tests/test_items.py b/tests/test_items.py index 4b202b26..966dcf4e 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -21,7 +21,7 @@ iter_returns, ) from mkapi.objects import create_module -from mkapi.utils import get_module_path +from mkapi.utils import get_by_name, get_module_path def _get_parameters(source: str): @@ -305,7 +305,7 @@ def f(x: int=0): assert src node = ast.parse(src) module = create_module(node, "x") - func = module.get_function("f") + func = get_by_name(module.functions, "f") assert func items_ast = func.parameters items_doc = func.doc.sections[0].items diff --git a/tests/test_objects.py b/tests/test_objects.py index 2772423f..17bff596 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -66,10 +66,10 @@ def test_create_function(get): assert "__mkapi__.module_level_function" in objects assert objects["__mkapi__.module_level_function"] is func assert len(func.parameters) == 4 - assert func.get_parameter("param1") - assert func.get_parameter("param2") - assert func.get_parameter("args") - assert func.get_parameter("kwargs") + assert get_by_name(func.parameters, "param1") + assert get_by_name(func.parameters, "param2") + assert get_by_name(func.parameters, "args") + assert get_by_name(func.parameters, "kwargs") assert len(func.returns) == 0 assert len(func.raises) == 1 assert len(func.doc.sections) == 4 @@ -85,16 +85,16 @@ def test_create_class(get): assert len(cls.parameters) == 0 assert len(cls.raises) == 0 assert len(cls.functions) == 6 - assert cls.get_function("__init__") - assert cls.get_function("example_method") - assert cls.get_function("__special__") - assert cls.get_function("__special_without_docstring__") - assert cls.get_function("_private") - assert cls.get_function("_private_without_docstring") + assert get_by_name(cls.functions, "__init__") + assert get_by_name(cls.functions, "example_method") + assert get_by_name(cls.functions, "__special__") + assert get_by_name(cls.functions, "__special_without_docstring__") + assert get_by_name(cls.functions, "_private") + assert get_by_name(cls.functions, "_private_without_docstring") assert len(cls.attributes) == 2 assert cls.get_attribute("readonly_property") assert cls.get_attribute("readwrite_property") - func = cls.get_function("__init__") + func = get_by_name(cls.functions, "__init__") assert isinstance(func, Function) assert func.qualname == "ExampleClass.__init__" assert func.fullname == "__mkapi__.ExampleClass.__init__" @@ -106,10 +106,10 @@ def test_create_module(google): assert module.name == "google" assert len(module.functions) == 4 assert len(module.classes) == 3 - cls = module.get_class("ExampleClass") + cls = get_by_name(module.classes, "ExampleClass") assert isinstance(cls, Class) assert cls.fullname == "google.ExampleClass" - func = cls.get_function("example_method") + func = get_by_name(cls.functions, "example_method") assert isinstance(func, Function) assert func.fullname == "google.ExampleClass.example_method" assert repr(module) == "Module(google)" @@ -117,9 +117,9 @@ def test_create_module(google): def test_fullname(google): module = create_module(google, "examples.styles.google") - c = module.get_class("ExampleClass") + c = get_by_name(module.classes, "ExampleClass") assert isinstance(c, Class) - f = c.get_function("example_method") + f = get_by_name(c.functions, "example_method") assert isinstance(f, Function) assert c.fullname == "examples.styles.google.ExampleClass" name = "examples.styles.google.ExampleClass.example_method" @@ -135,10 +135,10 @@ def test_relative_import(): assert src node = ast.parse(src) module = create_module(node, "x.y.z") - i = module.get_import("d") + i = get_by_name(module.imports, "d") assert i assert i.fullname == "x.y.z.c.d" - i = module.get_import("f") + i = get_by_name(module.imports, "f") assert i assert i.fullname == "x.y.e.f" @@ -159,7 +159,7 @@ def f(x: int=0, y: str='s')->bool: assert src node = ast.parse(src) module = create_module(node, "x") - func = module.get_function("f") + func = get_by_name(module.functions, "f") assert func merge_parameters(func) assert get_by_name(func.parameters, "x") @@ -199,11 +199,11 @@ class B(E,F.G): assert src node = ast.parse(src) module = create_module(node, "x") - cls = module.get_class("A") + cls = get_by_name(module.classes, "A") assert cls - func = cls.get_function("f") + func = get_by_name(cls.functions, "f") assert func - cls = func.get_class("B") + cls = get_by_name(func.classes, "B") assert cls assert cls.fullname == "x.A.f.B" objs = iter_objects(module) From 71de139232a6d8472e13ad669de898f66da324c5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 15 Jan 2024 14:20:35 +0900 Subject: [PATCH 097/148] TypeKind --- src/mkapi/importlib.py | 20 +++++++++++++------- tests/test_importlib.py | 4 +--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 82e05d6f..d0f3172a 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -5,12 +5,13 @@ import importlib import inspect import re +from functools import partial from typing import TYPE_CHECKING import mkapi.ast import mkapi.docstrings from mkapi.ast import iter_identifiers -from mkapi.items import Parameter +from mkapi.items import Parameter, TypeKind from mkapi.objects import ( Class, Module, @@ -202,20 +203,23 @@ def set_markdown(module: Module) -> None: # noqa: C901 """Set markdown with link form.""" cache: dict[str, str] = {} - def _get_link_type(name: str, asname: str | None = None) -> str: + def _get_link_type(name: str, asname: str) -> str: if name in cache: return cache[name] - asname = asname or name fullname = get_fullname(module, name) link = f"[{asname}][__mkapi__.{fullname}]" if fullname else asname cache[name] = link return link - def get_link_type(name: str) -> str: + def get_link_type(name: str, kind: TypeKind = TypeKind.REFERENCE) -> str: names = [] parents = iter_parent_modulenames(name) - for name_, asname in zip(parents, name.split("."), strict=True): - names.append(_get_link_type(name_, asname)) + asnames = name.split(".") + for k, (name_, asname) in enumerate(zip(parents, asnames, strict=True)): + if kind is TypeKind.OBJECT and k == len(asnames) - 1: + names.append(asname) + else: + names.append(_get_link_type(name_, asname)) return ".".join(names) def get_link_text(match: re.Match) -> str: @@ -227,7 +231,9 @@ def get_link_text(match: re.Match) -> str: for type_ in iter_types(module): if type_.expr: - type_.markdown = mkapi.ast.unparse(type_.expr, get_link_type) + get_link = partial(get_link_type, kind=type_.kind) + type_.markdown = mkapi.ast.unparse(type_.expr, get_link) + for text in iter_texts(module): if text.str: text.markdown = re.sub(LINK_PATTERN, get_link_text, text.str) diff --git a/tests/test_importlib.py b/tests/test_importlib.py index c84b407b..c7411d86 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -186,11 +186,9 @@ def test_set_markdown_objects(): module = load_module("mkapi.objects") assert module x = [t.markdown for t in iter_types(module)] - for z in x: - print(z) + assert "[mkapi][__mkapi__.mkapi].objects" in x # no link at last part. assert "[Class][__mkapi__.mkapi.objects.Class]" in x assert "list[[Raise][__mkapi__.mkapi.items.Raise]]" in x - assert "[mkapi][__mkapi__.mkapi].[objects][__mkapi__.mkapi.objects]" in x def test_set_markdown_plugins(): From 09ee222196e6f3e04c0c0db79c3f10c2519b4eef Mon Sep 17 00:00:00 2001 From: daizutabi Date: Mon, 15 Jan 2024 17:49:56 +0900 Subject: [PATCH 098/148] Delete abs_api_paths --- src/mkapi/items.py | 2 +- src/mkapi/pages.py | 87 ++++++++++++---------------------- src/mkapi/plugins.py | 102 ++++++++++++++++------------------------ tests/test_importlib.py | 5 +- tests/test_pages.py | 38 +++------------ tests/test_plugins.py | 3 +- 6 files changed, 81 insertions(+), 156 deletions(-) diff --git a/src/mkapi/items.py b/src/mkapi/items.py index acf62554..5b0faaf1 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -55,7 +55,7 @@ def __repr__(self) -> str: @dataclass(repr=False) class Parameter(Item): - """Parameter class for [Class] or [Function].""" + """Parameter class for [Class][mkapi.objects.Class] or [Function].""" default: ast.expr | None kind: _ParameterKind | None diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index 701d0625..777fff01 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -4,12 +4,13 @@ import os import re from dataclasses import dataclass, field +from functools import partial from pathlib import Path from typing import TYPE_CHECKING from mkapi import renderers -from mkapi.importlib import get_object, iter_texts, iter_types -from mkapi.objects import Class, Function, Module +from mkapi.importlib import get_object, load_module +from mkapi.objects import Class, Function, Module, iter_objects, iter_texts, iter_types from mkapi.utils import split_filters, update_filters # from mkapi.converter import convert_html, convert_object @@ -36,7 +37,6 @@ class Page: source: str abs_src_path: str - abs_api_paths: list[str] filters: list[list[str]] = field(default_factory=list) objects: list[Module | Class | Function] = field(default_factory=list, init=False) levels: list[int] = field(default_factory=list, init=False) @@ -51,7 +51,8 @@ def convert_markdown(self) -> str: # noqa: D102 markdowns.append(_object_markdown(markdown, index)) index += 1 markdown = "\n\n".join(markdowns) - return resolve_link(markdown, self.abs_src_path, self.abs_api_paths) + replace = partial(_replace_link, abs_src_path=self.abs_src_path) + return re.sub(LINK_PATTERN, replace, markdown) def _callback_markdown(self, name: str, level: int, filters: list[str]) -> str: obj = get_object(name) @@ -134,62 +135,32 @@ def convert_html( return html -LINK_PATTERN = re.compile(r"\[(\S+?)\]\[(\S+?)\]") +object_uris: dict[str, Path] = {} -def resolve_link(markdown: str, abs_src_path: str, abs_api_paths: list[str]) -> str: - """Reutrn resolved link. +def collect_objects(name: str | list[str], abs_path: Path) -> None: + """Collect objects for link.""" + if isinstance(name, list): + for name_ in name: + collect_objects(name_, abs_path) + return + if not (module := load_module(name)): + return + for obj in iter_objects(module): + object_uris.setdefault(obj.fullname, abs_path) + + +LINK_PATTERN = re.compile(r"\[(\S+?)\]\[(\S+?)\]") - Args: - markdown: Markdown source. - abs_src_path: Absolute source path of Markdown. - abs_api_paths: List of API paths. - - Examples: - >>> abs_src_path = '/src/examples/example.md' - >>> abs_api_paths = ['/api/a','/api/b', '/api/b.c'] - >>> resolve_link('[abc][b.c.d]', abs_src_path, abs_api_paths) - '[abc](../../api/b.c#b.c.d)' - >>> resolve_link('[abc][__mkapi__.b.c.d]', abs_src_path, abs_api_paths) - '[abc](../../api/b.c#b.c.d)' - >>> resolve_link('list[[abc][__mkapi__.b.c.d]]', abs_src_path, abs_api_paths) - 'list[[abc](../../api/b.c#b.c.d)]' - """ - def replace(match: re.Match) -> str: - name, href = match.groups() - if href.startswith("!__mkapi__."): # Just for MkAPI documentation. - href = href[11:] - return f"[{name}]({href})" +def _replace_link(match: re.Match, abs_src_path: str) -> str: + asname, fullname = match.groups() + if fullname.startswith("__mkapi__."): + from_mkapi = True + fullname = fullname[10:] + else: from_mkapi = False - if href.startswith("__mkapi__."): - href = href[10:] - from_mkapi = True - - if href := _resolve_href(href, abs_src_path, abs_api_paths): - # print(f"[{name}]({href})") - return f"[{name}]({href})" - return name if from_mkapi else match.group() - - return re.sub(LINK_PATTERN, replace, markdown) - - -def _resolve_href(name: str, abs_src_path: str, abs_api_paths: list[str]) -> str: - if not name: - return "" - abs_api_path = _match_last(name, abs_api_paths) - if not abs_api_path: - return "" - relpath = os.path.relpath(abs_api_path, Path(abs_src_path).parent) - relpath = relpath.replace("\\", "/") - return f"{relpath}#{name}" - - -# TODO: match longest. -def _match_last(name: str, abs_api_paths: list[str]) -> str: - match = "" - for abs_api_path in abs_api_paths: - _, path = os.path.split(abs_api_path) - if name.startswith(path[:-3]): - match = abs_api_path - return match + if uri := object_uris.get(fullname): + uri = uri.relative_to(Path(abs_src_path).parent, walk_up=True).as_posix() + return f"[{asname}]({uri}#{fullname})" + return asname if from_mkapi else match.group() diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 91674111..6cc1f34d 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -10,6 +10,7 @@ import os import re import sys +from functools import partial from pathlib import Path from typing import TYPE_CHECKING, TypeGuard @@ -22,15 +23,16 @@ import mkapi from mkapi import renderers +from mkapi.pages import collect_objects from mkapi.utils import find_submodulenames, is_package, split_filters, update_filters if TYPE_CHECKING: from collections.abc import Callable - from mkdocs.structure.nav import Navigation from mkdocs.structure.pages import Page as MkDocsPage from mkdocs.structure.toc import AnchorLink, TableOfContents - from mkdocs.utils.templates import TemplateContext + # from mkdocs.utils.templates import TemplateContext + # from mkdocs.structure.nav import Navigation from mkapi.pages import Page as MkAPIPage @@ -46,14 +48,13 @@ class MkAPIConfig(Config): page_title = config_options.Type(str, default="") section_title = config_options.Type(str, default="") on_config = config_options.Type(str, default="") - abs_api_paths = config_options.Type(list, default=[]) pages = config_options.Type(dict, default={}) class MkAPIPlugin(BasePlugin[MkAPIConfig]): """MkAPIPlugin class for API generation.""" - server = None + nav = None def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: _insert_sys_path(self.config) @@ -99,9 +100,7 @@ def on_page_markdown(self, markdown: str, page: MkDocsPage, **kwargs) -> str: """Convert Markdown source to intermediate version.""" # clean_page_title(page) abs_src_path = page.file.abs_src_path - abs_api_paths = self.config.abs_api_paths - filters = self.config.filters - mkapi_page = MkAPIPage(markdown, abs_src_path, abs_api_paths, filters) + mkapi_page = MkAPIPage(markdown, abs_src_path, self.config.filters) self.config.pages[abs_src_path] = mkapi_page return mkapi_page.convert_markdown() @@ -112,30 +111,29 @@ def on_page_content(self, html: str, page: MkDocsPage, **kwargs) -> str: mkapi_page: MkAPIPage = self.config.pages[page.file.abs_src_path] return mkapi_page.convert_html(html) - def on_page_context( - self, - context: TemplateContext, - page: MkDocsPage, - config: MkDocsConfig, - nav: Navigation, - **kwargs, - ) -> TemplateContext: - """Clear prefix in toc.""" - abs_src_path = page.file.abs_src_path - if abs_src_path in self.config.abs_api_paths: - pass - # clear_prefix(page.toc, 2) - else: - mkapi_page: MkAPIPage = self.config.pages[abs_src_path] - # for level, id_ in mkapi_page.headings: - # clear_prefix(page.toc, level, id_) - return context + # def on_page_context( + # self, + # context: TemplateContext, + # page: MkDocsPage, + # config: MkDocsConfig, + # nav: Navigation, + # **kwargs, + # ) -> TemplateContext: + # """Clear prefix in toc.""" + # src_uri = page.file.src_uri + # if src_uri in self.config.pages: + # pass + # # clear_prefix(page.toc, 2) + # else: + # mkapi_page: MkAPIPage = self.config.pages[abs_src_path] + # # for level, id_ in mkapi_page.headings: + # # clear_prefix(page.toc, level, id_) + # return context def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): - for path in ["themes"]: + for path in ["themes", "templates"]: path_str = (Path(mkapi.__file__).parent / path).as_posix() server.watch(path_str, builder) - self.__class__.server = server return server @@ -157,24 +155,23 @@ def _insert_sys_path(config: MkAPIConfig) -> None: sys.path.insert(0, path) -CACHE_CONFIG = {} - - def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: - if not plugin.server: - abs_api_paths = _update_nav(config, plugin) - plugin.config.abs_api_paths = abs_api_paths - CACHE_CONFIG["abs_api_paths"] = abs_api_paths - CACHE_CONFIG["nav"] = config.nav + if not MkAPIPlugin.nav: + _update_nav(config, plugin) + MkAPIPlugin.nav = config.nav else: - plugin.config.abs_api_paths = CACHE_CONFIG["abs_api_paths"] - config.nav = CACHE_CONFIG["nav"] + config.nav = MkAPIPlugin.nav def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: renderers.load_templates() +def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + create_pages = partial(_create_pages, config=config, plugin=plugin) + config.nav = _walk_nav(config.nav, create_pages) # type: ignore + + def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: nav_ = [] for item in nav: @@ -195,17 +192,6 @@ def _walk_nav(nav: list, create_pages: Callable[[str], list]) -> list: return nav_ -def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[Path]: - def create_pages(item: str) -> list: - nav, paths = _collect(item, config, plugin) - abs_api_paths.extend(paths) - return nav - - abs_api_paths: list[Path] = [] - config.nav = _walk_nav(config.nav, create_pages) # type: ignore - return list(set(abs_api_paths)) - - API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") @@ -255,11 +241,7 @@ def _get_function(plugin: MkAPIPlugin, name: str) -> Callable | None: return None -def _collect( - item: str, - config: MkDocsConfig, - plugin: MkAPIPlugin, -) -> tuple[list, list[Path]]: +def _create_pages(item: str, config: MkDocsConfig, plugin: MkAPIPlugin) -> list: """Collect modules.""" api_path, name, filters = _get_path_modulename_filters(item, plugin.config.filters) abs_api_path = Path(config.docs_dir) / api_path @@ -276,16 +258,14 @@ def predicate(name: str) -> bool: def callback(name: str, depth: int, ispackage) -> dict[str, str]: module_path = name + ".md" - abs_module_path = abs_api_path / module_path - abs_api_paths.append(abs_module_path) - _create_page(name, abs_module_path, filters) - nav_path = (Path(api_path) / module_path).as_posix() + abs_path = abs_api_path / module_path + collect_objects(name, abs_path) + _create_page(name, abs_path, filters) + src_uri = (Path(api_path) / module_path).as_posix() title = page_title(name, depth, ispackage) if page_title else name - return {title: nav_path} + return {title: src_uri} - abs_api_paths: list[Path] = [] - nav = _create_nav(name, callback, section_title, predicate) - return nav, abs_api_paths + return _create_nav(name, callback, section_title, predicate) def _create_page(name: str, path: Path, filters: list[str]) -> None: diff --git a/tests/test_importlib.py b/tests/test_importlib.py index c7411d86..9ac95617 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -78,8 +78,8 @@ def test_get_object(): def test_get_fullname(): module = load_module("mkapi.plugins") assert module - name = "mkdocs.utils.templates.TemplateContext" - assert get_fullname(module, "TemplateContext") == name + name = "mkdocs.structure.pages.Page" + assert get_fullname(module, "MkDocsPage") == name name = "mkdocs.config.config_options.Type" assert get_fullname(module, "config_options.Type") == name assert not get_fullname(module, "config_options.A") @@ -196,7 +196,6 @@ def test_set_markdown_plugins(): assert module x = [t.markdown for t in iter_types(module)] assert "[MkDocsPage][__mkapi__.mkdocs.structure.pages.Page]" in x - assert "tuple[list, list[[Path][__mkapi__.pathlib.Path]]]" in x def test_set_markdown_mkdocs(): diff --git a/tests/test_pages.py b/tests/test_pages.py index a234fd0b..2b5b7a3c 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -1,7 +1,9 @@ +from pathlib import Path + from markdown import Markdown from mkapi.objects import Class, iter_types -from mkapi.pages import Page, _iter_markdown +from mkapi.pages import Page, _iter_markdown, collect_objects source = """ # Title @@ -29,36 +31,10 @@ def callback_markdown(name, level, filters): def test_convert_markdown(): - abs_src_path = "/examples/docs/tutorial/index.md" - abs_api_paths = [ - "/examples/docs/api/mkapi.md", - "/examples/docs/api/mkapi.items.md", - ] - page = Page("::: mkapi.items.Parameters", abs_src_path, abs_api_paths) + collect_objects("mkapi.items", Path("/x/api/a.md")) + abs_src_path = "/b.md" + page = Page("::: mkapi.items.Parameters", abs_src_path) x = page.convert_markdown() assert "" in x assert "" in x - assert "[Section](../api/mkapi.items.md#mkapi.items.Section)" in x - assert "[Item](../api/mkapi.items.md#mkapi.items.Item) | None" in x - assert "[mkapi](../api/mkapi.md#mkapi).[items]" in x - assert "list[[Parameter](../api/mkapi.items.md" in x - page = Page("::: mkapi.items.Parameters", "", []) - x = page.convert_markdown() - assert "../api/mkapi.items" not in x - - -def test_convert_html(): - abs_src_path = "/examples/docs/tutorial/index.md" - abs_api_paths = [ - "/examples/docs/api/mkapi.md", - "/examples/docs/api/mkapi.items.md", - ] - page = Page("::: mkapi.items.Parameters", abs_src_path, abs_api_paths) - markdown = page.convert_markdown() - converter = Markdown() - html = converter.convert(markdown) - page.convert_html(html) - obj = page.objects[0] - assert isinstance(obj, Class) - x = "\n".join(x.html for x in iter_types(obj)) - assert '

    Item' in x + assert "[Section](x/api/a.md#mkapi.items.Section)" in x diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fca1bb8e..2df0520f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -67,7 +67,7 @@ def mkapi_plugin(mkdocs_config: MkDocsConfig): def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): assert isinstance(mkapi_plugin, MkAPIPlugin) - assert mkapi_plugin.server is None + assert mkapi_plugin.nav is None assert isinstance(mkapi_plugin.config, MkAPIConfig) @@ -82,7 +82,6 @@ def test_mkapi_config(mkapi_config: MkAPIConfig): assert config.on_config == "custom.on_config" assert config.filters == ["plugin_filter"] assert config.exclude == [".tests"] - assert config.abs_api_paths == [] def test_insert_sys_path(mkapi_config: MkAPIConfig): From 0242775071b0a10dab1364a92c2199d6da85a7c4 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 17 Jan 2024 22:31:42 +0900 Subject: [PATCH 099/148] nav --- examples/custom.py | 4 +- examples/docs/index.md | 6 +- pyproject.toml | 2 +- src/mkapi/converters.py | 34 ---- src/mkapi/importlib.py | 12 +- src/mkapi/items.py | 4 +- src/mkapi/nav.py | 147 ++++++++++++++++++ src/mkapi/nodes.py | 86 ---------- src/mkapi/pages.py | 3 +- src/mkapi/plugins.py | 31 ++-- src/mkapi/renderers.py | 26 ++-- src/mkapi/templates/memo.txt | 21 +++ src/mkapi/templates/object.jinja2 | 7 +- src/mkapi/utils.py | 12 +- tests/converter/test_a.py | 0 tests/renderers/__init__.py | 0 tests/renderers/test_module.py | 42 ----- tests/test_nav.py | 120 ++++++++++++++ tests/test_plugins.py | 70 +-------- .../test_templates.py => test_renderers.py} | 17 +- tests/test_utils.py | 16 +- 21 files changed, 372 insertions(+), 288 deletions(-) delete mode 100644 src/mkapi/converters.py create mode 100644 src/mkapi/nav.py delete mode 100644 src/mkapi/nodes.py create mode 100644 src/mkapi/templates/memo.txt delete mode 100644 tests/converter/test_a.py delete mode 100644 tests/renderers/__init__.py delete mode 100644 tests/renderers/test_module.py create mode 100644 tests/test_nav.py rename tests/{renderers/test_templates.py => test_renderers.py} (81%) diff --git a/examples/custom.py b/examples/custom.py index ec3f69ca..5b50f4f6 100644 --- a/examples/custom.py +++ b/examples/custom.py @@ -3,8 +3,8 @@ def on_config(config, mkapi): return config -def page_title(modulename: str, depth: int, ispackage: bool) -> str: - return ".".join(modulename.split(".")[depth:]) +def page_title(module_name: str, depth: int, ispackage: bool) -> str: + return ".".join(module_name.split(".")[depth:]) def section_title(package_name: str, depth: int) -> str: diff --git a/examples/docs/index.md b/examples/docs/index.md index 4bd5efd7..78e397db 100644 --- a/examples/docs/index.md +++ b/examples/docs/index.md @@ -1,3 +1,7 @@ # Home -::: mkapi.objects.Module +```python +def f(x, y=2): + pass + +``` diff --git a/pyproject.toml b/pyproject.toml index c504cf4d..56b9fd66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ packages = ["src/mkapi"] python = ["3.12"] [tool.hatch.envs.default] -dependencies = ["pytest-cov", "mkdocs-material"] +dependencies = ["pytest-cov", "mkdocs-material", 'polars'] [tool.hatch.envs.default.scripts] test = "pytest {args:tests src/mkapi}" diff --git a/src/mkapi/converters.py b/src/mkapi/converters.py deleted file mode 100644 index f43631d6..00000000 --- a/src/mkapi/converters.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Converter.""" -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -import mkapi.ast - -# from mkapi.converter import convert_html, convert_object -from mkapi.link import resolve_link -from mkapi.objects import load_module -from mkapi.utils import split_filters, update_filters - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator - - from mkapi.objects import Module - - -def convert_module(name: str, filters: list[str]) -> str: - """Convert the [Module] instance to markdown text.""" - if module := load_module(name): - # return renderer.render_module(module) - return f"{module}: {id(module)}" - return f"{name} not found" - - -def convert_object(name: str, level: int) -> str: - return "# ac" - - -def convert_html(name: str, html: str, filters: list[str]) -> str: - return f"xxxx {html}" diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index d0f3172a..bf6b358a 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -25,7 +25,7 @@ del_by_name, get_by_name, get_module_path, - iter_parent_modulenames, + iter_parent_module_names, ) if TYPE_CHECKING: @@ -66,8 +66,8 @@ def get_object(fullname: str) -> Module | Class | Function | None: return modules[fullname] if fullname in objects: return objects[fullname] - for modulename in iter_parent_modulenames(fullname): - if load_module(modulename) and fullname in objects: + for module_name in iter_parent_module_names(fullname): + if load_module(module_name) and fullname in objects: return objects[fullname] objects[fullname] = None return None @@ -168,10 +168,10 @@ def is_dataclass(cls: Class, module: Module | None = None) -> bool: def iter_dataclass_parameters(cls: Class) -> Iterator[Parameter]: """Yield [Parameter] instances for dataclass signature.""" - if not cls.module or not (modulename := cls.module.name): + if not cls.module or not (module_name := cls.module.name): raise NotImplementedError try: - module = importlib.import_module(modulename) + module = importlib.import_module(module_name) except ModuleNotFoundError: return members = dict(inspect.getmembers(module, inspect.isclass)) @@ -213,7 +213,7 @@ def _get_link_type(name: str, asname: str) -> str: def get_link_type(name: str, kind: TypeKind = TypeKind.REFERENCE) -> str: names = [] - parents = iter_parent_modulenames(name) + parents = iter_parent_module_names(name) asnames = name.split(".") for k, (name_, asname) in enumerate(zip(parents, asnames, strict=True)): if kind is TypeKind.OBJECT and k == len(asnames) - 1: diff --git a/src/mkapi/items.py b/src/mkapi/items.py index 5b0faaf1..60f5012d 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -10,7 +10,7 @@ from mkapi.ast import is_property from mkapi.utils import ( get_by_name, - iter_parent_modulenames, + iter_parent_module_names, join_without_first_indent, unique_names, ) @@ -131,7 +131,7 @@ def _iter_imports(node: ast.Import | ast.ImportFrom) -> Iterator[Import]: if alias.asname: yield Import(alias.asname, alias.name, None, 0) else: - for fullname in iter_parent_modulenames(alias.name): + for fullname in iter_parent_module_names(alias.name): yield Import(fullname, fullname, None, 0) else: name = alias.asname or alias.name diff --git a/src/mkapi/nav.py b/src/mkapi/nav.py new file mode 100644 index 00000000..3da8ee23 --- /dev/null +++ b/src/mkapi/nav.py @@ -0,0 +1,147 @@ +"""Navivgation utility functions.""" +from __future__ import annotations + +import re +from functools import partial +from typing import TYPE_CHECKING, TypeGuard + +from mkapi.utils import find_submodule_names, get_module_path, is_package, split_filters + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + from typing import Any + + +def get_apinav(name: str, predicate: Callable[[str], bool] | None = None) -> list: + """Return list of module names.""" + if m := re.match(r"^(.+?)\.(\*+)$", name): + name, option = m.groups() + n = len(option) + else: + n = 0 + if not get_module_path(name): + return [] + if not is_package(name): + return [name] + find = partial(find_submodule_names, predicate=predicate) + if n == 1: + return [name, *find(name)] + if n == 2: + return _get_apinav_list(name, find) + if n == 3: + return [_get_apinav_dict(name, find)] + return [name] + + +def _get_apinav_list(name: str, find: Callable[[str], list[str]]) -> list[str]: + names = [name] + for subname in find(name): + if is_package(subname): + names.extend(_get_apinav_list(subname, find)) + else: + names.append(subname) + return names + + +def _get_apinav_dict(name: str, find: Callable[[str], list[str]]) -> dict[str, list]: + names: list[str | dict] = [name] + for subname in find(name): + if is_package(subname): + names.append(_get_apinav_dict(subname, find)) + else: + names.append(subname) + return {name: names} + + +def gen_apinav(nav: list) -> Generator[tuple[str, bool], Any, None]: + """Yield tuple of (module name, is_section). + + Sent value is used to modify section names or nav items. + """ + for k, page in enumerate(nav): + if isinstance(page, str): + page_ = yield page, False + if page_: + nav[k] = page_ + elif isinstance(page, dict) and len(page) == 1: + section, pages = next(iter(page.items())) + section = yield section, True + if isinstance(section, str): + page.clear() + page[section] = pages + yield from gen_apinav(pages) + + +def update_apinav( + nav: list, + page: Callable[[str], str | dict[str, str]], + section: Callable[[str], str] | None = None, +) -> None: + """Update API navigation.""" + it = gen_apinav(nav) + name, is_section = it.send(None) + while True: + value = (section(name) if section else name) if is_section else page(name) + try: + name, is_section = it.send(value) + except StopIteration: + break + + +def create_nav(nav: list, create_apinav: Callable[[str], list]) -> list: + """Create navigation.""" + nav_ = [] + for item in nav: + if _is_api_entry(item): + nav_.extend(create_apinav(item)) + elif isinstance(item, dict) and len(item) == 1: + key, value = next(iter(item.items())) + if _is_api_entry(value): + value = create_apinav(value) + if len(value) == 1 and isinstance(value[0], str): + value = value[0] + elif isinstance(value, list): + value = create_nav(value, create_apinav) + nav_.append({key: value}) + else: + nav_.append(item) + return nav_ + + +API_URL_PATTERN = re.compile(r"^\<(.+)\>/(.+)$") + + +def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: + if not isinstance(item, str): + return False + return re.match(API_URL_PATTERN, item) is not None + + +def _split_path_name_filters(item: str) -> tuple[str, str, list[str]]: + if not (m := re.match(API_URL_PATTERN, item)): + raise NotImplementedError + path, name_filters = m.groups() + return path, *split_filters(name_filters) + + +def update_nav( + nav: list, + create_page: Callable[[str, str, list[str]], str | None], + section: Callable[[str], str] | None = None, + predicate: Callable[[str], bool] | None = None, +) -> list: + """Update navigation.""" + + def create_apinav(item: str) -> list: + api_path, name, filters = _split_path_name_filters(item) + nav = get_apinav(name, predicate) + + def page(name: str) -> str | dict[str, str]: + path = f"{api_path}/{name}.md" + title = create_page(name, path, filters) + return {title: path} if title else path + + update_apinav(nav, page, section) + return nav + + return create_nav(nav, create_apinav) diff --git a/src/mkapi/nodes.py b/src/mkapi/nodes.py deleted file mode 100644 index 25bbbca9..00000000 --- a/src/mkapi/nodes.py +++ /dev/null @@ -1,86 +0,0 @@ -# """Node class represents Markdown and HTML structure.""" -# from __future__ import annotations - -# from dataclasses import dataclass, field -# from typing import TYPE_CHECKING - -# from mkapi.importlib import get_object -# from mkapi.objects import Class, Function, Module -# from mkapi.utils import get_by_name - -# if TYPE_CHECKING: -# from collections.abc import Iterator - - -# @dataclass -# class Node: -# """Node class.""" - -# name: str -# object: Module | Class | Function # noqa: A003 -# members: list[Node] = field(default_factory=list, init=False) -# parent: Node | None = None -# html: str = field(init=False) - -# def __repr__(self) -> str: -# class_name = self.__class__.__name__ -# return f"{class_name}({self.name!r}, members={len(self)})" - -# def __iter__(self) -> Iterator[Node]: -# yield self -# for member in self.members: -# yield from member - -# def __len__(self) -> int: -# return len(self.members) - -# def __contains__(self, name: str) -> bool: -# if self.members: -# return any(member.object.name == name for member in self.members) -# return False - -# def get(self, name: str) -> Node | None: # noqa: D102 -# return get_by_name(self.members, name) if self.members else None - -# def get_kind(self) -> str: -# """Returns kind of self.""" -# raise NotImplementedError - -# def get_markdown(self, level: int, filters: list[str]) -> str: -# """Returns a Markdown source for docstring of self.""" -# markdowns = [] -# for node in self: -# markdowns.append(f"{node.id}\n\n") # noqa: PERF401 -# return "\n\n\n\n".join(markdowns) - -# def convert_html(self, html: str, level: int, filters: list[str]) -> str: -# htmls = html.split("") -# for node, html in zip(self.walk(), htmls, strict=False): -# node.html = html.strip() -# return "a" - -# @property -# def id(self): -# if isinstance(self.object, Module): -# return self.object.name -# return self.object.fullname - - -# def get_node(name: str) -> Node: -# """Return a [Node] instance from the object name.""" -# obj = get_object(name) -# if not isinstance(obj, Module | Class | Function): -# raise NotImplementedError -# return _get_node(obj, None) - - -# def _get_node(obj: Module | Class | Function, parent: Node | None) -> Node: -# node = Node(obj.name, obj, parent) -# if isinstance(obj, Module | Class): -# node.members = list(_iter_members(node, obj)) -# return node - - -# def _iter_members(node: Node, obj: Module | Class) -> Iterator[Node]: -# for member in obj.classes + obj.functions: -# yield _get_node(member, node) diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index 777fff01..7c645857 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -131,8 +131,7 @@ def convert_html( htmls = html.split("") for type_text, html in zip(_iter_type_text(obj), htmls, strict=True): type_text.html = html.strip() - # return renderers.render(obj, level, filters) - return html + return renderers.render(obj, level, filters) object_uris: dict[str, Path] = {} diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 6cc1f34d..f0e70e9e 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -18,13 +18,14 @@ from mkdocs.config import config_options from mkdocs.config.base import Config from mkdocs.config.defaults import MkDocsConfig -from mkdocs.plugins import BasePlugin +from mkdocs.plugins import BasePlugin, get_plugin_logger from mkdocs.structure.files import Files, get_files import mkapi from mkapi import renderers +from mkapi.pages import Page as MkAPIPage from mkapi.pages import collect_objects -from mkapi.utils import find_submodulenames, is_package, split_filters, update_filters +from mkapi.utils import find_submodule_names, is_package, split_filters, update_filters if TYPE_CHECKING: from collections.abc import Callable @@ -34,9 +35,7 @@ # from mkdocs.utils.templates import TemplateContext # from mkdocs.structure.nav import Navigation -from mkapi.pages import Page as MkAPIPage - -logger = logging.getLogger("mkdocs") +logger = get_plugin_logger("MkAPI") class MkAPIConfig(Config): @@ -139,7 +138,7 @@ def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: if func := _get_function(plugin, "on_config"): - msg = f"[MkAPI] Calling user 'on_config': {plugin.config.on_config}" + msg = f"Calling user 'on_config': {plugin.config.on_config}" logger.info(msg) config_ = func(config, plugin) if isinstance(config_, MkDocsConfig): @@ -157,7 +156,7 @@ def _insert_sys_path(config: MkAPIConfig) -> None: def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: if not MkAPIPlugin.nav: - _update_nav(config, plugin) + _update_apinav(config, plugin) MkAPIPlugin.nav = config.nav else: config.nav = MkAPIPlugin.nav @@ -167,7 +166,7 @@ def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: renderers.load_templates() -def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: +def _update_apinav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: create_pages = partial(_create_pages, config=config, plugin=plugin) config.nav = _walk_nav(config.nav, create_pages) # type: ignore @@ -201,16 +200,16 @@ def _is_api_entry(item: str | list | dict) -> TypeGuard[str]: return re.match(API_URL_PATTERN, item) is not None -def _get_path_modulename_filters( +def _get_path_module_name_filters( item: str, filters: list[str], ) -> tuple[str, str, list[str]]: if not (m := re.match(API_URL_PATTERN, item)): raise NotImplementedError - path, modulename_filter = m.groups() - modulename, filters_ = split_filters(modulename_filter) + path, module_name_filter = m.groups() + module_name, filters_ = split_filters(module_name_filter) filters = update_filters(filters, filters_) - return path, modulename, filters + return path, module_name, filters def _create_nav( @@ -220,7 +219,7 @@ def _create_nav( predicate: Callable[[str], bool] | None = None, depth: int = 0, ) -> list: - names = find_submodulenames(name, predicate) + names = find_submodule_names(name, predicate) tree: list = [callback(name, depth, is_package(name))] for sub in names: if not is_package(sub): @@ -235,15 +234,15 @@ def _create_nav( def _get_function(plugin: MkAPIPlugin, name: str) -> Callable | None: if fullname := plugin.config.get(name, None): - modulename, func_name = fullname.rsplit(".", maxsplit=1) - module = importlib.import_module(modulename) + module_name, func_name = fullname.rsplit(".", maxsplit=1) + module = importlib.import_module(module_name) return getattr(module, func_name) return None def _create_pages(item: str, config: MkDocsConfig, plugin: MkAPIPlugin) -> list: """Collect modules.""" - api_path, name, filters = _get_path_modulename_filters(item, plugin.config.filters) + api_path, name, filters = _get_path_module_name_filters(item, plugin.config.filters) abs_api_path = Path(config.docs_dir) / api_path Path.mkdir(abs_api_path, parents=True, exist_ok=True) diff --git a/src/mkapi/renderers.py b/src/mkapi/renderers.py index da92bfcd..0b969f54 100644 --- a/src/mkapi/renderers.py +++ b/src/mkapi/renderers.py @@ -62,8 +62,8 @@ def render(obj: Module | Class | Function, level: int, filters: list[str]) -> st level: Heading level. filters: Filters. """ - return "" obj_str = render_object(obj, filters=filters) + return obj_str doc_str = render_docstring(obj.doc, filters=filters) members = [] for member in obj.classes + obj.functions: @@ -79,18 +79,18 @@ def render_object(obj: Module | Class | Function, filters: list[str]) -> str: obj: Object instance. filters: Filters. """ - context = resolve_object(obj.html) - level = context.get("level") - if level: - if obj.kind in ["module", "package"]: - filters.append("plain") - elif "plain" in filters: - del filters[filters.index("plain")] - tag = f"h{level}" - else: - tag = "div" - template = self.templates["object"] - return template.render(context, object=obj, tag=tag, filters=filters) + # context = resolve_object(obj.html) + # level = context.get("level") + # if level: + # if obj.kind in ["module", "package"]: + # filters.append("plain") + # elif "plain" in filters: + # del filters[filters.index("plain")] + # tag = f"h{level}" + # else: + # tag = "div" + # template = self.templates["object"] + # return template.render(context, object=obj, tag=tag, filters=filters) # def render_object_member( # self, diff --git a/src/mkapi/templates/memo.txt b/src/mkapi/templates/memo.txt new file mode 100644 index 00000000..abca5295 --- /dev/null +++ b/src/mkapi/templates/memo.txt @@ -0,0 +1,21 @@ + +

    +
    +class='property' class, classmethod, property + + sig-name, sig-paren, sig-param, sig-colon, sig-ann, sig-default, sig-eq +
    +
    +

    description

    +
    +
    Parameters
    +
    +
      +
    • param – desc

    • +
    +
    +
    +
    +
    + +→ diff --git a/src/mkapi/templates/object.jinja2 b/src/mkapi/templates/object.jinja2 index f7a0a111..bc2482bb 100644 --- a/src/mkapi/templates/object.jinja2 +++ b/src/mkapi/templates/object.jinja2 @@ -1,13 +1,14 @@ {% from 'macros.jinja2' import object_body -%} -{% set upper = 'upper' in filters -%} {% set body_tag = 'span' if 'plain' in filters else 'code' -%} {% set top = ' top' if '.' not in object.qualname else '' -%} {% set kind_class = object.kind.replace(' ', '-') -%}
    -
    {% if upper and object.kind in ['package', 'module'] %}{{ object.kind|upper }}{% else %}{{ object.kind }}{% endif %}
    - <{{tag}}{% if heading_id %} id="{{ heading_id }}"{% endif %} class="mkapi-object-body {{ kind_class }}{{ top }}">{{ object_body(object, prefix_url, name_url, body_tag, upper, filters) }} +
    {{ object.kind }}
    + <{{tag}}{% if heading_id %} id="{{ heading_id }}"{% endif %} class="mkapi-object-body {{ kind_class }}{{ top }}"> + {{ object_body(object, prefix_url, name_url, body_tag, upper, filters) }} +
    {% if 'sourcelink' in filters or 'link' in filters or 'apilink' in filters -%}
  • {% endfor %} diff --git a/src/mkapi/templates/source.jinja2 b/src/mkapi/templates/source.jinja2 index 826da134..a29a5ec4 100644 --- a/src/mkapi/templates/source.jinja2 +++ b/src/mkapi/templates/source.jinja2 @@ -1,3 +1,8 @@ +--- +search: + exclude: true +--- + {% if heading %}<{{ heading }} id="{{ obj.fullname }}" class="mkapi-heading" markdown="1">{% endif %} {{ fullname|safe }} {%- if heading %}{% endif %} diff --git a/src/mkapi/themes/js/mkapi.js b/src/mkapi/themes/js/mkapi.js deleted file mode 100644 index f0d41b69..00000000 --- a/src/mkapi/themes/js/mkapi.js +++ /dev/null @@ -1,11 +0,0 @@ -function flush() { - var id = location.hash.replace(/\./g, "\\.") - if (id.length) { - console.log(id); - // $("" + id).css("background-color","#9f9"); - } -} - -$(flush); - -// $("html").click(flush) diff --git a/src/mkapi/themes/css/mkapi-common.css b/src/mkapi/themes/mkapi-material.css similarity index 100% rename from src/mkapi/themes/css/mkapi-common.css rename to src/mkapi/themes/mkapi-material.css diff --git a/src/mkapi/themes/mkapi.yml b/src/mkapi/themes/mkapi.yml deleted file mode 100644 index b3a7337a..00000000 --- a/src/mkapi/themes/mkapi.yml +++ /dev/null @@ -1,3 +0,0 @@ -extra_css: - - //use.fontawesome.com/releases/v5.8.1/css/all.css - - //use.fontawesome.com/releases/v5.8.1/css/v4-shims.css diff --git a/examples/__init__.py b/tests/examples/__init__.py similarity index 100% rename from examples/__init__.py rename to tests/examples/__init__.py diff --git a/examples/styles/__init__.py b/tests/examples/styles/__init__.py similarity index 100% rename from examples/styles/__init__.py rename to tests/examples/styles/__init__.py diff --git a/examples/styles/example_google.py b/tests/examples/styles/example_google.py similarity index 100% rename from examples/styles/example_google.py rename to tests/examples/styles/example_google.py diff --git a/examples/styles/example_numpy.py b/tests/examples/styles/example_numpy.py similarity index 100% rename from examples/styles/example_numpy.py rename to tests/examples/styles/example_numpy.py diff --git a/tests/test_items.py b/tests/test_items.py index a04e6108..17b35558 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -108,7 +108,7 @@ def test_get_attributes(): def load_module(name): - path = str(Path(__file__).parent.parent) + path = str(Path(__file__).parent) if path not in sys.path: sys.path.insert(0, str(path)) path = get_module_path(name) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index b55acc82..7c17906d 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -81,40 +81,6 @@ def test_add_link_items(): assert lines[2] == "* [__mkapi__.pqr][]: stu" -def c(text: str) -> str: - text = inspect.cleandoc(text) - e = ["admonition", "pymdownx.superfences", "attr_list", "md_in_html"] - return markdown.markdown(text, extensions=e) - - -def test_replace_examples(): - src = """ - !!! Note - - abc - - >>> a = 1 - >>> print(a) - 1 - - def - - >>> def f(): - ... pass - - ghi - - jkl - """ - src = inspect.cleandoc(src) - text = replace_examples(src) - m = c(text) - assert '
    ' in m - assert '

    ' in m - assert '
    ' in m - assert '
    ' in m - - def test_replace_examples_prompt_only(): src = """ abc @@ -128,6 +94,12 @@ def test_replace_examples_prompt_only(): assert "\n```{.python .mkapi-example-input}\na = 1\n\nb = 1" in text +def c(text: str) -> str: + text = inspect.cleandoc(text) + e = ["admonition", "pymdownx.superfences", "attr_list", "md_in_html"] + return markdown.markdown(text, extensions=e) + + def test_replace_directives(): src = """ abc @@ -162,39 +134,55 @@ def test_replace_directives_deprecated(): assert '

    Deprecated since version 1.0

    ' in m -def test_replace_directives_codeblock(): +def test_replace_examples(): src = """ - abc + !!! Note - .. note:: - a b c + abc - .. code-block:: python + >>> a = 1 + >>> print(a) + 1 - a = 1 + def - b = 1 + >>> def f(): + ... pass - d e f + ghi + + jkl + """ + src = inspect.cleandoc(src) + text = replace_examples(src) + m = c(text) + print(m) + assert '
    ' in m + assert '
    ' in m + assert '
    ' in m + assert "```" not in m + + +def test_replace_directives_codeblock(): + src = """ + abc .. note:: a b c - .. code-block:: python + .. code-block:: python - a = 1 + a = 1 - b = 1 + b = 1 d e f - """ src = inspect.cleandoc(src) text = replace_directives(src) - print(text) m = c(text) - print(m) - assert 0 + assert '
    ' in m + assert "

    a b c

    \n
    a = 1\n\nb = 1\n
    " in m def get(module: str, n1: str, n2: str | None = None) -> str: @@ -218,14 +206,12 @@ def get(module: str, n1: str, n2: str | None = None) -> str: def test_polars_collect(): src = get("polars.lazyframe.frame", "LazyFrame", "collect") - print(src) doc = parse(src) s = get_by_name(doc.sections, "Parameters") assert s i = get_by_name(s.items, "streaming") assert i assert i.text.str - print(i.text.str) assert "!!! warning\n This functionality" in i.text.str @@ -241,21 +227,16 @@ def test_polars_from_numpy(): def test_polars_a(): - src = get("polars.dataframe.frame", "DataFrame", "group_by_dynamic") + # src = get("polars.dataframe.frame", "DataFrame", "map_rows") + src = get("polars.config", "Config") print(src) m = convert(src) print(m) - doc = parse(src) - for s in doc.sections: - print("--" * 40) - print(s.text.str) - print("--" * 40) - assert 0 - - -# polars.dataframe.frame.DataFrame.write_delta `here_` -# polars.dataframe.frame.DataFrame.map_rows -# polars.expr.datetime.ExprDateTimeNameSpace.round deprecated -# DataFrame.to_arrow() list -# DataFrame.to_init_repr See also list -# polars.config.Config.set_tbl_cell_alignment parameters list + # doc = parse(src) + # for i in doc.sections[0].items: + # print("--" * 40) + # print(i.text) + # print("--" * 40) + # import markdown + + # print(markdown.markdown(i.text.str)) diff --git a/tests/test_objects.py b/tests/test_objects.py index 057bb978..dfc09d66 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -25,7 +25,7 @@ @pytest.fixture(scope="module") def google(): - path = str(Path(__file__).parent.parent) + path = str(Path(__file__).parent) if path not in sys.path: sys.path.insert(0, str(path)) return get_module_node("examples.styles.example_google") diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 977bd57d..a26edc2f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,104 +1,84 @@ -import importlib.util -from pathlib import Path +# import importlib.util +# from pathlib import Path -import pytest -from jinja2.environment import Environment -from mkdocs.commands.build import build -from mkdocs.config import load_config -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.plugins import PluginCollection -from mkdocs.theme import Theme +# import pytest +# from jinja2.environment import Environment +# from mkdocs.config import load_config +# from mkdocs.config.defaults import MkDocsConfig +# from mkdocs.plugins import PluginCollection +# from mkdocs.theme import Theme -import mkapi -from mkapi.plugins import ( - MkAPIConfig, - MkAPIPlugin, - _insert_sys_path, -) +# import mkapi +# from mkapi.plugins import ( +# MkAPIConfig, +# MkAPIPlugin, +# _insert_sys_path, +# ) -@pytest.fixture(scope="module") -def config_file(): - return Path(mkapi.__file__).parent.parent.parent / "examples" / "mkdocs.yml" +# @pytest.fixture(scope="module") +# def config_file(): +# return Path(__file__).parent / "examples" / "mkdocs.yml" -def test_config_file_exists(config_file: Path): - assert config_file.exists() +# def test_config_file_exists(config_file: Path): +# print(config_file) +# assert config_file.exists() -def test_themes_templates_exists(): - for path in ["themes", "templates"]: - assert (Path(mkapi.__file__).parent / path).exists() +# def test_themes_templates_exists(): +# for path in ["themes", "templates"]: +# assert (Path(mkapi.__file__).parent / path).exists() -@pytest.fixture(scope="module") -def mkdocs_config(config_file: Path): - return load_config(str(config_file)) +# @pytest.fixture(scope="module") +# def mkdocs_config(config_file: Path): +# return load_config(str(config_file)) -def test_mkdocs_config(mkdocs_config: MkDocsConfig): - config = mkdocs_config - assert isinstance(config, MkDocsConfig) - path = Path(config.config_file_path) - assert path.as_posix().endswith("mkapi/examples/mkdocs.yml") - assert config.site_name == "MkAPI" - assert Path(config.docs_dir) == path.parent / "docs" - assert Path(config.site_dir) == path.parent / "site" - assert config.nav[0] == "index.md" # type: ignore - assert isinstance(config.plugins, PluginCollection) - assert isinstance(config.plugins["mkapi"], MkAPIPlugin) - assert config.pages is None - assert isinstance(config.theme, Theme) - assert config.theme.name == "material" - assert isinstance(config.theme.get_env(), Environment) - assert config.extra_css == ["custom.css"] - assert str(config.extra_javascript[0]).endswith("tex-mml-chtml.js") - assert "pymdownx.arithmatex" in config.markdown_extensions - - -@pytest.fixture(scope="module") -def mkapi_plugin(mkdocs_config: MkDocsConfig): - return mkdocs_config.plugins["mkapi"] - - -def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): - assert isinstance(mkapi_plugin, MkAPIPlugin) - assert mkapi_plugin.nav is None - assert isinstance(mkapi_plugin.config, MkAPIConfig) - - -@pytest.fixture(scope="module") -def mkapi_config(mkapi_plugin: MkAPIPlugin): - return mkapi_plugin.config - - -def test_mkapi_config(mkapi_config: MkAPIConfig): - config = mkapi_config - assert config.src_dirs == ["."] - # assert config.on_config == "custom.on_config" - assert config.filters == ["plugin_filter"] - assert config.exclude == [".tests"] - - -def test_insert_sys_path(mkdocs_config, mkapi_plugin): - assert not importlib.util.find_spec("custom") - _insert_sys_path(mkdocs_config, mkapi_plugin) - spec = importlib.util.find_spec("custom") - assert spec - assert spec.origin - assert spec.origin.endswith("custom.py") - - -# def test_on_config_plugin(mkdocs_config, mkapi_plugin): -# config = _on_config_plugin(mkdocs_config, mkapi_plugin) -# assert mkdocs_config is config - - -# def test_mkdocs_build(mkdocs_config: MkDocsConfig): +# def test_mkdocs_config(mkdocs_config: MkDocsConfig): # config = mkdocs_config -# config.nav = [{"API": "/polars.dataframe.frame"}] -# config.plugins.on_startup(command="build", dirty=False) -# try: -# build(config) -# finally: -# config.plugins.on_shutdown() +# assert isinstance(config, MkDocsConfig) +# path = Path(config.config_file_path) +# assert path.as_posix().endswith("mkapi/examples/mkdocs.yml") +# assert config.site_name == "MkAPI" +# assert Path(config.docs_dir) == path.parent / "docs" +# assert Path(config.site_dir) == path.parent / "site" +# assert config.nav[0] == "index.md" # type: ignore +# assert isinstance(config.plugins, PluginCollection) +# assert isinstance(config.plugins["mkapi"], MkAPIPlugin) +# assert config.pages is None +# assert isinstance(config.theme, Theme) +# assert config.theme.name == "material" +# assert isinstance(config.theme.get_env(), Environment) + + +# @pytest.fixture(scope="module") +# def mkapi_plugin(mkdocs_config: MkDocsConfig): +# return mkdocs_config.plugins["mkapi"] + + +# def test_mkapi_plugin(mkapi_plugin: MkAPIPlugin): +# assert isinstance(mkapi_plugin, MkAPIPlugin) +# assert mkapi_plugin.nav is None +# assert isinstance(mkapi_plugin.config, MkAPIConfig) + + +# @pytest.fixture(scope="module") +# def mkapi_config(mkapi_plugin: MkAPIPlugin): +# return mkapi_plugin.config + + +# def test_mkapi_config(mkapi_config: MkAPIConfig): +# config = mkapi_config +# assert config.filters == ["plugin_filter"] +# assert config.exclude == [".tests"] + + +# def test_insert_sys_path(mkdocs_config, mkapi_plugin): +# assert not importlib.util.find_spec("custom") +# _insert_sys_path(mkdocs_config, mkapi_plugin) +# spec = importlib.util.find_spec("custom") +# assert spec +# assert spec.origin +# assert spec.origin.endswith("custom.py") diff --git a/tests/test_renderers.py b/tests/test_renderers.py index eb483884..788af851 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -11,10 +11,12 @@ def test_load_templates(): def test_render(): - obj = get_object("polars.dataframe.frame") + name = "polars.config.Config.set_tbl_cell_alignment" + obj = get_object(name) assert obj m = render(obj, 1, []) print(m) print("-" * 100) h = markdown.markdown(m, extensions=["md_in_html"]) print(h) + # assert 0 From 9d3bdc23bba61a9ae06954d2b13dd5c8b7ca7a84 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 3 Feb 2024 06:17:32 +0900 Subject: [PATCH 134/148] import_from --- custom.py | 12 ++++++ docs/1.md | 9 +++++ docs/2.md | 7 ++++ docs/index.md | 7 ++++ mkdocs.yml | 84 ++++++++++++++++++++++------------------- pyproject.toml | 29 ++++++++++---- src/mkapi/__about__.py | 1 + src/mkapi/__init__.py | 1 + src/mkapi/globals.py | 6 ++- src/mkapi/importlib.py | 2 +- src/mkapi/items.py | 10 +++-- src/mkapi/markdown.py | 4 +- src/mkapi/objects.py | 3 +- src/mkapi/plugins.py | 10 ++--- src/mkapi/utils.py | 9 ++--- tests/test_globals.py | 35 ++++++++--------- tests/test_importlib.py | 5 +++ tests/test_inspect.py | 8 +--- tests/test_markdown.py | 24 +----------- tests/test_objects.py | 4 +- 20 files changed, 152 insertions(+), 118 deletions(-) create mode 100644 custom.py create mode 100644 docs/1.md create mode 100644 docs/2.md create mode 100644 docs/index.md diff --git a/custom.py b/custom.py new file mode 100644 index 00000000..8b4e33a1 --- /dev/null +++ b/custom.py @@ -0,0 +1,12 @@ +def on_config(config, mkapi): + return config + + +def page_title(name: str, depth: int) -> str: + return name + # return ".".join(module_name.split(".")[depth:]) + + +def section_title(name: str, depth: int) -> str: + return name + # return ".".join(package_name.split(".")[depth:]) diff --git a/docs/1.md b/docs/1.md new file mode 100644 index 00000000..5e315ff4 --- /dev/null +++ b/docs/1.md @@ -0,0 +1,9 @@ +# 31 + +## \_\_a\_\_.\_\_b\_\_ {#\_\_b\_\_} + +## ab + +dea + +::: polars.datafram diff --git a/docs/2.md b/docs/2.md new file mode 100644 index 00000000..839c1e48 --- /dev/null +++ b/docs/2.md @@ -0,0 +1,7 @@ +# 2 + +## 2-1 + +## 2-2 + +## 2-3 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..78e397db --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +# Home + +```python +def f(x, y=2): + pass + +``` diff --git a/mkdocs.yml b/mkdocs.yml index b54addc7..b3fd63a0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,50 +1,56 @@ site_name: MkAPI -site_url: https://mkapi.daizutabi.net/ +site_url: https://daizutabi.github.io/mkapi/ site_description: API documentation with MkDocs. site_author: daizutabi -site_dir: ../mkapi-site/site - repo_url: https://github.com/daizutabi/mkapi/ edit_uri: "" - theme: name: material - highlightjs: true - hljs_languages: - - yaml - -extra_css: - - custom.css - + font: + text: Fira Sans + code: Fira Code + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - scheme: slate + primary: black + accent: black + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + features: + - content.tooltips + - navigation.expand + - navigation.indexes + - navigation.instant + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.tracking plugins: - - search + - search: - mkapi: - src_dirs: [examples] - on_config: custom.on_config - -nav: - - index.md - - Examples: - - examples/google_style.md - - examples/numpy_style.md - - Advanced Usage: - - usage/module.md - - usage/inherit.md - - usage/page.md - - usage/filter.md - - usage/library.py - - usage/custom.md - - Appendix: - - appendix/type.py - - appendix/method.md - - appendix/inherit.md - - appendix/order.md - - appendix/decorator.md - - API: mkapi/api/mkapi|upper|strict - + exclude: [.tests] + filters: [plugin_filter] + page_title: custom.page_title + section_title: custom.section_title + # on_config: custom.on_config markdown_extensions: - pymdownx.arithmatex - -extra_javascript: - - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js - # - https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-MML-AM_CHTML \ No newline at end of file + - pymdownx.magiclink +nav: + - index.md + # - /mkapi.objects|nav_filter1|nav_filter2 + - Section: + - 1.md + # - /mkapi.**|nav_filter3 + - 2.md + # - MkDocs: /mkdocs.**|nav_filter4 + # - Polars: /polars.dataframe.***|nav_filter4 + - SchemDraw: /schemdraw.*** + - Polars: /polars.*** + # - Altair: /altair.*** \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 28b9db0a..5f92555a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,23 +11,19 @@ authors = [{ name = "daizutabi", email = "daizutabi@gmail.com" }] classifiers = [ "Development Status :: 4 - Beta", "Framework :: MkDocs", - "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Topic :: Documentation", "Topic :: Software Development :: Documentation", ] dynamic = ["version"] requires-python = ">=3.12" -dependencies = ["jinja2", "mkdocs", "halo", "tqdm"] +dependencies = ["jinja2", "mkdocs", "halo", "tqdm", "mkdocs-material"] [project.urls] Documentation = "https://github.com/daizutabi/mkapi#readme" # FIXME Source = "https://github.com/daizutabi/mkapi" Issues = "https://github.com/daizutabi/mkapi/issues" -[project.scripts] -mkapi = "mkapi.main:cli" - [project.entry-points."mkdocs.plugins"] mkapi = "mkapi.plugins:MkAPIPlugin" @@ -43,7 +39,14 @@ packages = ["src/mkapi"] python = ["3.12"] [tool.hatch.envs.default] -dependencies = ["pytest-cov", "mkdocs-material", "polars", "markdown"] +dependencies = [ + "mkdocs", + "mkdocs-material", + "polars", + "altair", + "schemdraw", + "pyparsing", +] [tool.hatch.envs.default.scripts] test = "pytest {args:tests src/mkapi}" @@ -58,6 +61,7 @@ addopts = [ doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] filterwarnings = [ 'ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning', + 'ignore:\nPyarrow will become a required dependency:DeprecationWarning', ] [tool.coverage.run] @@ -67,7 +71,14 @@ omit = ["src/mkapi/__about__.py"] exclude_lines = ["no cov", "raise NotImplementedError", "if TYPE_CHECKING:"] [tool.hatch.envs.docs] -dependencies = ["mkdocs", "mkdocs-material", 'polars'] +dependencies = [ + "mkdocs", + "mkdocs-material", + "polars", + "altair", + "schemdraw", + "pyparsing", +] [tool.hatch.envs.docs.scripts] build = "mkdocs build --clean --strict {args}" serve = "mkdocs serve --dev-addr localhost:8000 {args}" @@ -87,9 +98,13 @@ ignore = [ "D407", "EM102", "ERA001", + "FIX002", + "ISC001", + "COM812", "N812", "PGH003", "PLR2004", + "TD003", "TRY003", ] exclude = ["examples"] diff --git a/src/mkapi/__about__.py b/src/mkapi/__about__.py index d293e3a3..ef371127 100644 --- a/src/mkapi/__about__.py +++ b/src/mkapi/__about__.py @@ -1 +1,2 @@ +"""Dynamic version.""" __version__ = "1.2.0" diff --git a/src/mkapi/__init__.py b/src/mkapi/__init__.py index e69de29b..2275f8c7 100644 --- a/src/mkapi/__init__.py +++ b/src/mkapi/__init__.py @@ -0,0 +1 @@ +"""MkAPI package.""" diff --git a/src/mkapi/globals.py b/src/mkapi/globals.py index 9dfa3dff..98472b0b 100644 --- a/src/mkapi/globals.py +++ b/src/mkapi/globals.py @@ -12,6 +12,7 @@ get_by_name, get_module_node, get_module_path, + is_package, iter_identifiers, iter_parent_module_names, ) @@ -101,7 +102,10 @@ def _iter_imports_from_import_from( module = parent elif node.level: names = parent.split(".") - prefix = ".".join(names[: len(names) - node.level + 1]) + if is_package(parent): + prefix = ".".join(names[: len(names) - node.level + 1]) + else: + prefix = ".".join(names[: -node.level]) module = f"{prefix}.{node.module}" else: module = node.module diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 26447519..491732e4 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -104,7 +104,7 @@ def iter_base_classes(cls: Class) -> Iterator[Class]: def inherit_base_classes(cls: Class) -> None: """Inherit objects from base classes.""" - # TODO: fix InitVar, ClassVar for dataclasses. + # TODO(daizutabi): fix InitVar, ClassVar for dataclasses. bases = list(iter_base_classes(cls)) for name in ["attributes", "functions", "classes"]: members = {} diff --git a/src/mkapi/items.py b/src/mkapi/items.py index f4cd2dab..a0fbaa85 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -47,7 +47,8 @@ def __repr__(self) -> str: args = ast.unparse(self.expr) if self.expr else "" return f"{self.__class__.__name__}({args})" - def copy(self) -> Self: # noqa: D102 + def copy(self) -> Self: + """Copy an instance.""" type_ = self.__class__(self.expr) type_.markdown = self.markdown type_.kind = self.kind @@ -87,14 +88,15 @@ def set_markdown(self, module: str) -> None: # noqa: ARG002 class Text: """Text class.""" - str: str | None = None + str: str | None = None # noqa: A003, RUF100 markdown: str = field(default="", init=False) def __repr__(self) -> str: text = self.str or "" return f"{self.__class__.__name__}({text!r})" - def copy(self) -> Self: # noqa: D102 + def copy(self) -> Self: + """Copy an instance.""" text = self.__class__(self.str) text.markdown = self.markdown return text @@ -105,7 +107,7 @@ class Item: """Element class.""" name: str - type: Type + type: Type # noqa: A003, RUF100 text: Text def __repr__(self) -> str: diff --git a/src/mkapi/markdown.py b/src/mkapi/markdown.py index 9e51f86c..169e9714 100644 --- a/src/mkapi/markdown.py +++ b/src/mkapi/markdown.py @@ -181,8 +181,8 @@ def _iter_code_block(it: Iterable[str], lang: str) -> Iterator[str]: yield from _get_code_block(codes, lang, indent) -def _get_code_block(codes: list[str], lang: str, indent: int) -> Iterator[str]: - prefix = " " * indent +def _get_code_block(codes: list[str], lang: str, indent: int) -> Iterator[str]: # noqa: ARG001 + prefix = " " * indent # noqa: F841 # yield f"{prefix}```{{.{lang} .mkapi-example}}" # yield f"{prefix}~~~{lang}" for stop in range(len(codes) - 1, 0, -1): diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 0507d28f..2d688ed0 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -39,7 +39,6 @@ from collections.abc import Iterator from mkapi.items import Base, Parameter, Raise, Return - from mkapi.objects import Attribute, Function objects: dict[str, Module | Class | Function | Attribute | None] = {} @@ -90,7 +89,7 @@ class Attribute(Member): """Attribute class.""" node: ast.AnnAssign | ast.Assign | ast.TypeAlias | ast.FunctionDef | None - type: Type + type: Type # noqa: A003, RUF100 default: Default text: Text = field(default_factory=Text, init=False) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 70f9f9ce..816d7360 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -18,7 +18,7 @@ from halo import Halo from mkdocs.config import Config, config_options from mkdocs.plugins import BasePlugin, get_plugin_logger -from mkdocs.structure.files import Files, InclusionLevel, get_files +from mkdocs.structure.files import InclusionLevel, get_files from tqdm.std import tqdm import mkapi @@ -135,14 +135,14 @@ def _get_function(name: str, plugin: MkAPIPlugin) -> Callable | None: return None -def _insert_sys_path(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: +def _insert_sys_path(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 config_dir = Path(config.config_file_path).parent path = os.path.normpath(config_dir) if path not in sys.path: sys.path.insert(0, path) -def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: +def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 renderers.load_templates() @@ -155,7 +155,7 @@ def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: config.nav = MkAPIPlugin.nav -def _update_extensions(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: +def _update_extensions(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 for name in [ "admonition", "attr_list", @@ -236,7 +236,7 @@ def predicate(name: str) -> bool: # return config -def _collect_theme_files(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[File]: +def _collect_theme_files(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[File]: # noqa: ARG001 root = Path(mkapi.__file__).parent / "themes" docs_dir = config.docs_dir config.docs_dir = root.as_posix() diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 471eb87b..463ab8bf 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import ast -import doctest import re from functools import cache from importlib.util import find_spec @@ -162,13 +161,13 @@ def update_filters(org: list[str], update: list[str]) -> list[str]: """Update filters. Examples: - >>> update_filters(['upper'], ['lower']) + >>> update_filters(["upper"], ["lower"]) ['lower'] - >>> update_filters(['lower'], ['upper']) + >>> update_filters(["lower"], ["upper"]) ['upper'] - >>> update_filters(['long'], ['short']) + >>> update_filters(["long"], ["short"]) ['short'] - >>> update_filters(['short'], ['long']) + >>> update_filters(["short"], ["long"]) ['long'] """ filters = org + update diff --git a/tests/test_globals.py b/tests/test_globals.py index 0c4d4b2b..e85d8bda 100644 --- a/tests/test_globals.py +++ b/tests/test_globals.py @@ -5,6 +5,7 @@ from mkapi.globals import ( Global, + _iter_imports, _iter_imports_from_import, _iter_imports_from_import_from, _iter_objects_from_all, @@ -57,24 +58,6 @@ def test_resolve(): assert resolve("mkdocs.config.Config") == "mkdocs.config.base.Config" -def test_relative_import(): - """# test module - from .c import d - from ..e import f as F - """ - src = inspect.getdoc(test_relative_import) - assert src - node = ast.parse(src) - x = node.body[0] - assert isinstance(x, ast.ImportFrom) - i = next(_iter_imports_from_import_from(x, "x.y.z")) - assert i == ("d", "x.y.z.c.d") - x = node.body[1] - assert isinstance(x, ast.ImportFrom) - i = next(_iter_imports_from_import_from(x, "x.y.z")) - assert i == ("F", "x.y.e.f") - - @pytest.mark.parametrize( ("name", "fullname"), [ @@ -97,6 +80,20 @@ def test_get_globals_polars(): assert n +def test_get_globals_schemdraw(): + from schemdraw.elements.cables import Element2Term, Segment # type: ignore + + x = get_globals("schemdraw.elements.cables") + n = get_by_name(x.names, "Segment") + assert n + a = f"{Segment.__module__}.{Segment.__name__}" + assert n.fullname == a + n = get_by_name(x.names, "Element2Term") + assert n + a = f"{Element2Term.__module__}.{Element2Term.__name__}" + assert n.fullname == a + + def test_get_globals_cache(): a = get_globals("mkapi.plugins") b = get_globals("mkapi.plugins") @@ -140,7 +137,7 @@ def test_get_fullname(): x = get_fullname("polars.dataframe.frame", "DataType") assert x == "polars.datatypes.classes.DataType" x = get_fullname("polars.dataframe.frame", "Workbook") - # assert x == "dd" + assert x == "xlsxwriter.Workbook" def test_get_link_from_type(): diff --git a/tests/test_importlib.py b/tests/test_importlib.py index 2b8437ec..fd8fda43 100644 --- a/tests/test_importlib.py +++ b/tests/test_importlib.py @@ -95,3 +95,8 @@ def test_iter_dataclass_parameters(): assert p[2].name == "text" assert p[3].name == "items" assert p[4].name == "kind" + + +def test_a(): # TODO: delete + module = load_module("schemdraw.elements.cables") + print(module) diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 354cdae9..139ddba3 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -88,13 +88,7 @@ def test_markdown_polars(DataFrame): # noqa: N803 assert isinstance(func, Function) sig = get_signature(func) m = sig.markdown - assert r'\*' in m - # func = get_by_name(cls.functions, "write_csv") - # assert isinstance(func, Function) - # sig = get_signature(func) - # m = sig.markdown - # print(m) - # assert 0 + assert r'\*' in m def test_method(DataFrame): # noqa: N803 diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 7c17906d..320b0d3b 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -204,29 +204,7 @@ def get(module: str, n1: str, n2: str | None = None) -> str: return src -def test_polars_collect(): - src = get("polars.lazyframe.frame", "LazyFrame", "collect") - doc = parse(src) - s = get_by_name(doc.sections, "Parameters") - assert s - i = get_by_name(s.items, "streaming") - assert i - assert i.text.str - assert "!!! warning\n This functionality" in i.text.str - - -def test_polars_from_numpy(): - src = get("polars.convert", "from_numpy") - doc = parse(src) - s = get_by_name(doc.sections, "Parameters") - assert s - i = get_by_name(s.items, "data") - assert i - assert i.type.expr - assert ast.unparse(i.type.expr) == "'numpy.ndarray'" - - -def test_polars_a(): +def test_a(): # src = get("polars.dataframe.frame", "DataFrame", "map_rows") src = get("polars.config", "Config") print(src) diff --git a/tests/test_objects.py b/tests/test_objects.py index dfc09d66..274ba187 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -7,10 +7,8 @@ import pytest from mkapi.ast import iter_child_nodes -from mkapi.items import Parameters, SeeAlso from mkapi.objects import ( LINK_PATTERN, - Attribute, Class, Function, create_class, @@ -20,7 +18,7 @@ merge_items, objects, ) -from mkapi.utils import get_by_name, get_by_type, get_module_node +from mkapi.utils import get_by_name, get_module_node @pytest.fixture(scope="module") From c441828e650d72d1fb1f206b650a99f2b2d3d2f5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 3 Feb 2024 11:58:07 +0900 Subject: [PATCH 135/148] return name --- mkdocs.yml | 9 +++---- pyproject.toml | 20 +++------------ src/mkapi/docstrings.py | 12 +++++---- src/mkapi/importlib.py | 57 ++++++++++++++++++++++------------------- src/mkapi/items.py | 5 +++- src/mkapi/plugins.py | 33 +++++++++++++++++++----- src/mkapi/renderers.py | 7 ++--- tests/test_globals.py | 2 -- 8 files changed, 77 insertions(+), 68 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index b3fd63a0..aa4bae78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,10 +34,11 @@ theme: plugins: - search: - mkapi: - exclude: [.tests] + exclude: [altair.vegalite] filters: [plugin_filter] page_title: custom.page_title section_title: custom.section_title + debug: true # on_config: custom.on_config markdown_extensions: - pymdownx.arithmatex @@ -49,8 +50,6 @@ nav: - 1.md # - /mkapi.**|nav_filter3 - 2.md - # - MkDocs: /mkdocs.**|nav_filter4 - # - Polars: /polars.dataframe.***|nav_filter4 - - SchemDraw: /schemdraw.*** + - Schemdraw: /schemdraw.*** - Polars: /polars.*** - # - Altair: /altair.*** \ No newline at end of file + - Altair: /altair.*** \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5f92555a..53990ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dynamic = ["version"] requires-python = ">=3.12" -dependencies = ["jinja2", "mkdocs", "halo", "tqdm", "mkdocs-material"] +dependencies = ["halo", "jinja2", "mkdocs", "mkdocs-material", "tqdm"] [project.urls] Documentation = "https://github.com/daizutabi/mkapi#readme" # FIXME @@ -39,14 +39,7 @@ packages = ["src/mkapi"] python = ["3.12"] [tool.hatch.envs.default] -dependencies = [ - "mkdocs", - "mkdocs-material", - "polars", - "altair", - "schemdraw", - "pyparsing", -] +dependencies = ["polars", "pytest-cov", "schemdraw"] [tool.hatch.envs.default.scripts] test = "pytest {args:tests src/mkapi}" @@ -71,14 +64,7 @@ omit = ["src/mkapi/__about__.py"] exclude_lines = ["no cov", "raise NotImplementedError", "if TYPE_CHECKING:"] [tool.hatch.envs.docs] -dependencies = [ - "mkdocs", - "mkdocs-material", - "polars", - "altair", - "schemdraw", - "pyparsing", -] +dependencies = ["altair", "polars", "pyparsing", "schemdraw"] [tool.hatch.envs.docs.scripts] build = "mkdocs build --clean --strict {args}" serve = "mkdocs serve --dev-addr localhost:8000 {args}" diff --git a/src/mkapi/docstrings.py b/src/mkapi/docstrings.py index 73f630ab..e4ad37b4 100644 --- a/src/mkapi/docstrings.py +++ b/src/mkapi/docstrings.py @@ -40,9 +40,11 @@ def _iter_items(section: str) -> Iterator[str]: """ start = 0 for m in SPLIT_ITEM_PATTERN.finditer(section): - yield section[start : m.start()].strip() + if item := section[start : m.start()].strip(): + yield item start = m.start() - yield section[start:].strip() + if item := section[start:].strip(): + yield item def _split_item_google(lines: list[str]) -> tuple[str, str, str]: @@ -92,7 +94,7 @@ def iter_items(section: str, style: Style) -> Iterator[tuple[str, Type, Text]]: SPLIT_SECTION_PATTERNS: dict[Style, re.Pattern[str]] = { "google": re.compile(r"\n\n\S"), - "numpy": re.compile(r"\n\n\n\S|\n\n.+?\n-+\n"), + "numpy": re.compile(r"\n\n\n\S|\n\n.+?\n[\-=]+\n"), } @@ -145,7 +147,7 @@ def get_style(doc: str) -> Style: """ for names in SECTION_NAMES: for name in names: - if f"\n\n{name}\n----" in doc: + if f"\n\n{name}\n----" in doc or f"\n\n{name}\n====" in doc: CURRENT_DOCSTRING_STYLE[0] = "numpy" return "numpy" CURRENT_DOCSTRING_STYLE[0] = "google" @@ -167,7 +169,7 @@ def split_section(section: str, style: Style) -> tuple[str, str]: if style == "google" and re.match(r"^([A-Za-z0-9][^:]*):$", lines[0]): text = textwrap.dedent("\n".join(lines[1:])) return lines[0][:-1], text - if style == "numpy" and re.match(r"^-+?$", lines[1]): + if style == "numpy" and re.match(r"^[\-=]+?$", lines[1]): text = textwrap.dedent("\n".join(lines[2:])) return lines[0], text return "", section diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 491732e4..e03b20ed 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -1,6 +1,7 @@ """importlib module.""" from __future__ import annotations +import re from functools import cache from typing import TYPE_CHECKING @@ -125,8 +126,6 @@ def add_sections(module: Module) -> None: add_functions(obj) if isinstance(obj, Module | Class): add_attributes(obj) - # if module.kind == "package": - # add_sections_package(module) def add_classes(obj: Module | Class) -> None: @@ -153,39 +152,28 @@ def add_attributes(obj: Module | Class) -> None: obj.doc.sections.append(section) -def _iter_items(objs: Iterable[Function | Class | Attribute]) -> Iterator[Item]: - for obj in objs: - if is_empty(obj): - continue - type_ = obj.doc.type.copy() - text = obj.doc.text.copy() - type_.markdown = type_.markdown.split("..")[-1] - text.markdown = text.markdown.split("\n\n")[0] - yield Item("", type_, text) +ASNAME_PATTERN = re.compile(r"^\[.+?\]\[(__mkapi__\..+?)\]$") -def add_sections_package(module: Module) -> None: +def add_sections_for_package(module: Module) -> None: """Add __all__ members for a package.""" - names = get_all(module.name) modules = [] classes = [] functions = [] attributes = [] - for name, fullname in names.items(): + for name, fullname in get_all(module.name).items(): if obj := get_object(fullname): - type_ = obj.doc.type.copy() - text = obj.doc.text.copy() - type_.markdown = ".".join(type_.markdown.split("..")) - text.markdown = text.markdown.split("\n\n")[0] - item = Item(name, type_, text) - if isinstance(obj, Module): - modules.append(item) - elif isinstance(obj, Class): - classes.append(item) - elif isinstance(obj, Function): - functions.append(item) - elif isinstance(obj, Attribute): - attributes.append(item) + item = _get_item(obj) + asname = f"[{name}][\\1]" + item.type.markdown = ASNAME_PATTERN.sub(asname, item.type.markdown) + if isinstance(obj, Module): + modules.append(item) + elif isinstance(obj, Class): + classes.append(item) + elif isinstance(obj, Function): + functions.append(item) + elif isinstance(obj, Attribute): + attributes.append(item) it = [ (modules, "Modules"), (classes, "Classes"), @@ -196,3 +184,18 @@ def add_sections_package(module: Module) -> None: if items: section = Section(name, Type(), Text(), items) module.doc.sections.append(section) + + +def _iter_items(objs: Iterable[Function | Class | Attribute]) -> Iterator[Item]: + for obj in objs: + if is_empty(obj): + continue + yield _get_item(obj) + + +def _get_item(obj: Module | Class | Function | Attribute) -> Item: + type_ = obj.doc.type.copy() + text = obj.doc.text.copy() + type_.markdown = type_.markdown.split("..")[-1] + text.markdown = text.markdown.split("\n\n")[0] + return Item("", type_, text) diff --git a/src/mkapi/items.py b/src/mkapi/items.py index a0fbaa85..bac0d1fb 100644 --- a/src/mkapi/items.py +++ b/src/mkapi/items.py @@ -343,9 +343,12 @@ class Returns(Section): def create_returns(name: str, text: str, style: str) -> Returns: """Return a returns section.""" type_, text_ = split_without_name(text, style) + name_ = "" + if ":" in type_: + name_, type_ = type_.split(":", maxsplit=1) type_ = Type(ast.Constant(type_) if type_ else None) text_ = Text(text_ or None) - returns = [Return("", type_, text_)] + returns = [Return(name_, type_, text_)] return Returns(name, Type(), Text(), returns) diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 816d7360..d2b9669b 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -6,6 +6,7 @@ from __future__ import annotations import importlib +import itertools import os import re import shutil @@ -52,6 +53,7 @@ class MkAPIConfig(Config): section_title = config_options.Type(str, default="") src_anchor = config_options.Type(str, default="source") src_dir = config_options.Type(str, default="src") + debug = config_options.Type(bool, default=False) # on_config = config_options.Type(str, default="") @@ -62,6 +64,7 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): api_dirs: ClassVar[list] = [] api_uris: ClassVar[list] = [] api_srcs: ClassVar[list] = [] + api_uri_width: ClassVar[int] = 0 def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: _insert_sys_path(config, self) @@ -91,7 +94,15 @@ def on_page_markdown(self, markdown: str, page: MkDocsPage, **kwargs) -> str: """Convert Markdown source to intermediate version.""" path = page.file.abs_src_path filters = self.config.filters - return convert_markdown(markdown, path, filters, self.config.src_anchor) + anchor = self.config.src_anchor + try: + return convert_markdown(markdown, path, filters, anchor) + except Exception as e: # noqa: BLE001 + if self.config.debug: + raise + msg = f"{page.file.src_uri}:{type(e).__name__}: {e}" + logger.warning(msg) + return markdown def on_page_content( self, @@ -103,20 +114,28 @@ def on_page_content( """Merge HTML and MkAPI's object structure.""" if page.file.src_uri in MkAPIPlugin.api_uris: _replace_toc(page.toc) - self.bar.update(1) + self._update_bar(page.file.src_uri) if page.file.src_uri in MkAPIPlugin.api_srcs: path = Path(config.docs_dir) / page.file.src_uri html = convert_source(html, path, self.config.docs_anchor) - self.bar.update(1) + self._update_bar(page.file.src_uri) return html + def _update_bar(self, uri: str) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + uri = uri.ljust(MkAPIPlugin.api_uri_width) + self.bar.set_postfix_str(uri, refresh=False) + self.bar.update(1) + def on_post_build(self, *, config: MkDocsConfig) -> None: self.bar.close() def on_serve(self, server, config: MkDocsConfig, builder, **kwargs): - for path in ["themes", "templates"]: - path_str = (Path(mkapi.__file__).parent / path).as_posix() - server.watch(path_str, builder) + if self.config.debug: + for path in ["themes", "templates"]: + path_str = (Path(mkapi.__file__).parent / path).as_posix() + server.watch(path_str, builder) return server def on_shutdown(self) -> None: @@ -151,6 +170,8 @@ def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: _create_nav(config, plugin) _update_nav(config, plugin) MkAPIPlugin.nav = config.nav + uris = itertools.chain(MkAPIPlugin.api_uris, MkAPIPlugin.api_srcs) + MkAPIPlugin.api_uri_width = max(len(uri) for uri in uris) else: config.nav = MkAPIPlugin.nav diff --git a/src/mkapi/renderers.py b/src/mkapi/renderers.py index 9e8fc8d1..9fe55ed0 100644 --- a/src/mkapi/renderers.py +++ b/src/mkapi/renderers.py @@ -8,7 +8,7 @@ from jinja2 import Environment, FileSystemLoader, Template, select_autoescape import mkapi -from mkapi.importlib import add_sections_package, get_source +from mkapi.importlib import add_sections_for_package, get_source from mkapi.inspect import get_signature from mkapi.objects import Attribute, Class, Function, Module @@ -43,7 +43,7 @@ def render( qualnames = [[x, "prefix"] for x in names] qualnames[-1][1] = "name" if isinstance(obj, Module) and obj.kind == "package": - add_sections_package(obj) + add_sections_for_package(obj) context = { "heading": heading, "id": id_, @@ -81,6 +81,3 @@ def _render_source(obj: Module, context: dict[str, Any], filters: list[str]) -> source = "" context["source"] = source return templates["source"].render(context) - - # if module.kind == "package": - # add_sections_package(module) diff --git a/tests/test_globals.py b/tests/test_globals.py index e85d8bda..3d837a95 100644 --- a/tests/test_globals.py +++ b/tests/test_globals.py @@ -1,11 +1,9 @@ import ast -import inspect import pytest from mkapi.globals import ( Global, - _iter_imports, _iter_imports_from_import, _iter_imports_from_import_from, _iter_objects_from_all, From d741df60bb0fe493047a9f7719b17a9cb519c609 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 3 Feb 2024 17:24:49 +0900 Subject: [PATCH 136/148] config.py --- config.py | 30 +++++++++++ custom.py | 12 ----- mkdocs.yml | 10 ++-- pyproject.toml | 1 + src/mkapi/importlib.py | 2 +- src/mkapi/nav.py | 3 +- src/mkapi/objects.py | 46 +++++++++++++--- src/mkapi/plugins.py | 84 ++++++++++++++--------------- src/mkapi/templates/source.jinja2 | 5 -- src/mkapi/themes/mkapi-material.css | 1 + tests/test_objects.py | 48 ++++++++++++++++- 11 files changed, 164 insertions(+), 78 deletions(-) create mode 100644 config.py delete mode 100644 custom.py diff --git a/config.py b/config.py new file mode 100644 index 00000000..aee36943 --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +"""Config functions.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mkdocs.config.defaults import MkDocsConfig + + from mkapi.plugins import MkAPIPlugin + + +def on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 + """Called after `on_config` event of MkAPI plugin.""" + + +def page_title(name: str, depth: int) -> str: # noqa: ARG001 + """Return a page title.""" + return name + # return ".".join(name.split(".")[depth:]) + + +def section_title(name: str, depth: int) -> str: # noqa: ARG001 + """Return a section title.""" + return name + # return ".".join(name.split(".")[depth:]) + + +def toc_title(name: str, depth: int) -> str: # noqa: ARG001 + """Return a toc title.""" + return name.split(".")[-1] # Remove prefix. diff --git a/custom.py b/custom.py deleted file mode 100644 index 8b4e33a1..00000000 --- a/custom.py +++ /dev/null @@ -1,12 +0,0 @@ -def on_config(config, mkapi): - return config - - -def page_title(name: str, depth: int) -> str: - return name - # return ".".join(module_name.split(".")[depth:]) - - -def section_title(name: str, depth: int) -> str: - return name - # return ".".join(package_name.split(".")[depth:]) diff --git a/mkdocs.yml b/mkdocs.yml index aa4bae78..a8a6f5d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,10 +34,12 @@ theme: plugins: - search: - mkapi: - exclude: [altair.vegalite] - filters: [plugin_filter] - page_title: custom.page_title - section_title: custom.section_title + config: config.py + exclude: + - altair.vegalite + # filters: [plugin_filter] + # page_title: custom.page_title + # section_title: custom.section_title debug: true # on_config: custom.on_config markdown_extensions: diff --git a/pyproject.toml b/pyproject.toml index 53990ffe..6abe6c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ ignore = [ "N812", "PGH003", "PLR2004", + "TD002", "TD003", "TRY003", ] diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index e03b20ed..91fdb77a 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -105,7 +105,7 @@ def iter_base_classes(cls: Class) -> Iterator[Class]: def inherit_base_classes(cls: Class) -> None: """Inherit objects from base classes.""" - # TODO(daizutabi): fix InitVar, ClassVar for dataclasses. + # TODO: fix InitVar, ClassVar for dataclasses. bases = list(iter_base_classes(cls)) for name in ["attributes", "functions", "classes"]: members = {} diff --git a/src/mkapi/nav.py b/src/mkapi/nav.py index 5b816d48..701867a2 100644 --- a/src/mkapi/nav.py +++ b/src/mkapi/nav.py @@ -141,14 +141,13 @@ def update( create_page: Callable[[str, str, list[str], int], str | None], section: Callable[[str, int], str] | None = None, predicate: Callable[[str], bool] | None = None, - index_name: str = "README", ) -> None: """Update navigation.""" def _create_apinav(name: str, api_path: str, filters: list[str]) -> list: def page(name: str, depth: int) -> str | dict[str, str]: if is_package(name): - path = name.replace(".", "/") + f"/{index_name}.md" + path = name.replace(".", "/") + "/README.md" else: path = name.replace(".", "/") + ".md" path = f"{api_path}/{path}" diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py index 2d688ed0..8205f49f 100644 --- a/src/mkapi/objects.py +++ b/src/mkapi/objects.py @@ -24,6 +24,7 @@ Text, Type, TypeKind, + _assign_type_text, create_attributes, create_raises, iter_assigns, @@ -136,7 +137,7 @@ class Function(Callable): def __iter__(self) -> Iterator[Type | Text]: """Yield [Type] or [Text] instances.""" - for item in self.parameters + self.returns: + for item in itertools.chain(self.parameters, self.returns): yield from item @@ -149,10 +150,10 @@ def create_function( module = module or _create_empty_module() text = ast.get_docstring(node) doc = docstrings.parse(text) - parameters = list(iter_parameters(node)) + params = list(iter_parameters(node)) returns = list(iter_returns(node)) raises = list(iter_raises(node)) - func = Function(node.name, node, doc, module, parent, parameters, returns, raises) + func = Function(node.name, node, doc, module, parent, params, returns, raises) for child in mkapi.ast.iter_child_nodes(node): if isinstance(child, ast.ClassDef): clss = create_class(child, module, func) @@ -175,7 +176,7 @@ class Class(Callable): def __iter__(self) -> Iterator[Type | Text]: """Yield [Type] or [Text] instances.""" - for item in self.attributes + self.parameters: + for item in itertools.chain(self.attributes, self.parameters): yield from item @@ -226,9 +227,8 @@ def __iter__(self) -> Iterator[Type | Text]: def _create_empty_module() -> Module: - name = "__mkapi__" doc = Docstring("Docstring", Type(), Text(), []) - return Module(name, ast.Module(), doc, None) + return Module("__mkapi__", ast.Module(), doc, None) def create_module(name: str, node: ast.Module, source: str | None = None) -> Module: @@ -260,11 +260,32 @@ def merge_items(module: Module) -> None: if isinstance(obj, Function): merge_returns(obj) if isinstance(obj, Module | Class): + _add_doc_comments(obj.attributes, module.source) merge_attributes(obj) if isinstance(obj, Class): merge_bases(obj) +def _add_doc_comments(attrs: list[Attribute], source: str | None = None) -> None: + if not source: + return + lines = source.splitlines() + for attr in attrs: + if attr.doc.text.str or not (node := attr.node): + continue + line = lines[node.lineno - 1][node.end_col_offset :].strip() + if line.startswith("#:"): + _add_doc_comment(attr, line[2:].strip()) + elif node.lineno > 1: + line = lines[node.lineno - 2][node.col_offset :] + if line.startswith("#:"): + _add_doc_comment(attr, line[2:].strip()) + + +def _add_doc_comment(attr: Attribute, text: str) -> None: + attr.type, attr.doc.text = _assign_type_text(attr.type.expr, text) + + def merge_parameters(obj: Class | Function) -> None: """Merge parameters.""" if not (section := get_by_type(obj.doc.sections, Parameters)): @@ -301,6 +322,10 @@ def merge_returns(obj: Function) -> None: def merge_attributes(obj: Module | Class) -> None: """Merge attributes.""" if not (section := get_by_type(obj.doc.sections, Assigns)): + # attrs = [attr for attr in obj.attributes if attr.doc.text.str] + # if attrs: + # section = create_attributes(attrs) + # obj.doc.sections.append(section) return index = obj.doc.sections.index(section) module = obj if isinstance(obj, Module) else obj.module @@ -314,8 +339,13 @@ def merge_attributes(obj: Module | Class) -> None: attr_doc.type = attr_ast.type if not attr_doc.default.expr: attr_doc.default = attr_ast.default - if not attr_ast.text.str: - attr_ast.text.str = attr_doc.text.str + if not attr_doc.doc.text.str: + attr_doc.doc.text.str = attr_ast.doc.text.str + # if not attr_ast.text.str: + # attr_ast.text.str = attr_doc.text.str + for attr_ast in obj.attributes: + if not get_by_name(section.items, attr_ast.name): + section.items.append(attr_ast) def merge_bases(obj: Class) -> None: diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index d2b9669b..395f938c 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -46,15 +46,13 @@ class MkAPIConfig(Config): """Specify the config schema.""" + config = config_options.Type(str, default="") + debug = config_options.Type(bool, default=False) docs_anchor = config_options.Type(str, default="docs") exclude = config_options.Type(list, default=[]) filters = config_options.Type(list, default=[]) - page_title = config_options.Type(str, default="") - section_title = config_options.Type(str, default="") src_anchor = config_options.Type(str, default="source") src_dir = config_options.Type(str, default="src") - debug = config_options.Type(bool, default=False) - # on_config = config_options.Type(str, default="") class MkAPIPlugin(BasePlugin[MkAPIConfig]): @@ -67,12 +65,12 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): api_uri_width: ClassVar[int] = 0 def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: - _insert_sys_path(config, self) _update_templates(config, self) _update_config(config, self) _update_extensions(config, self) + if on_config := _get_function("on_config", self): + on_config(config, self) return config - # return _on_config_plugin(config, self) def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: """Collect plugin CSS/JavaScript and append them to `files`.""" @@ -112,8 +110,9 @@ def on_page_content( **kwargs, ) -> str: """Merge HTML and MkAPI's object structure.""" + toc_title = _get_function("toc_title", self) if page.file.src_uri in MkAPIPlugin.api_uris: - _replace_toc(page.toc) + _replace_toc(page.toc, toc_title) self._update_bar(page.file.src_uri) if page.file.src_uri in MkAPIPlugin.api_srcs: path = Path(config.docs_dir) / page.file.src_uri @@ -147,18 +146,19 @@ def on_shutdown(self) -> None: def _get_function(name: str, plugin: MkAPIPlugin) -> Callable | None: - if fullname := plugin.config.get(name, None): - module_name, func_name = fullname.rsplit(".", maxsplit=1) - module = importlib.import_module(module_name) - return getattr(module, func_name) - return None - - -def _insert_sys_path(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 - config_dir = Path(config.config_file_path).parent - path = os.path.normpath(config_dir) - if path not in sys.path: - sys.path.insert(0, path) + if not (path_str := plugin.config.config): + return None + if not path_str.endswith(".py"): + module = importlib.import_module(path_str) + else: + path = Path(path_str) + if not path.is_absolute(): + path = Path(plugin.config.config_file_path).parent / path + directory = os.path.normpath(path.parent) + sys.path.insert(0, directory) + module = importlib.import_module(path.stem) + del sys.path[0] + return getattr(module, name, None) def _update_templates(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 @@ -177,13 +177,7 @@ def _update_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: def _update_extensions(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 - for name in [ - "admonition", - "attr_list", - "def_list", - "md_in_html", - "pymdownx.superfences", - ]: + for name in ["admonition", "attr_list", "md_in_html", "pymdownx.superfences"]: if name not in config.markdown_extensions: config.markdown_extensions.append(name) @@ -221,10 +215,11 @@ def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: if not config.nav: return - page = _get_function("page_title", plugin) - section = _get_function("section_title", plugin) + page_title = _get_function("page_title", plugin) + section_title = _get_function("section_title", plugin) def _create_page(name: str, path: str, filters: list[str], depth: int) -> str: + spinner.text = f"Updating nav...: {name}" MkAPIPlugin.api_uris.append(path) abs_path = Path(config.docs_dir) / path _check_path(abs_path) @@ -236,25 +231,17 @@ def _create_page(name: str, path: str, filters: list[str], depth: int) -> str: _check_path(abs_path) create_source_page(f"{name}.**", abs_path, filters) - return page(name, depth) if page else name + return page_title(name, depth) if page_title else name def predicate(name: str) -> bool: return any(ex not in name for ex in plugin.config.exclude) with warnings.catch_warnings(): warnings.simplefilter("ignore") - with Halo(text="Updating nav...", spinner="dots"): - mkapi.nav.update(config.nav, _create_page, section, predicate) - - -# def _on_config_plugin(config: MkDocsConfig, plugin: MkAPIPlugin) -> MkDocsConfig: -# if func := _get_function("on_config", plugin): -# msg = f"Calling {plugin.config.on_config!r}" -# logger.info(msg) -# config_ = func(config, plugin) -# if isinstance(config_, MkDocsConfig): -# return config_ -# return config + spinner = Halo() + spinner.start() + mkapi.nav.update(config.nav, _create_page, section_title, predicate) + spinner.stop() def _collect_theme_files(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[File]: # noqa: ARG001 @@ -289,9 +276,16 @@ def _collect_theme_files(config: MkDocsConfig, plugin: MkAPIPlugin) -> list[File return files -def _replace_toc(toc: TableOfContents | list[AnchorLink]) -> None: +def _replace_toc( + toc: TableOfContents | list[AnchorLink], + title: Callable[[str, int], str] | None = None, + depth: int = 0, +) -> None: for link in toc: - link.id = link.id.replace("\0295\03", "_") + # link.id = link.id.replace("\0295\03", "_") link.title = re.sub(r"\s+\[.+?\]", "", link.title) # Remove source link. - link.title = link.title.split(".")[-1] # Remove prefix. - _replace_toc(link.children) + if title: + link.title = title(link.title, depth) + else: + link.title = link.title.split(".")[-1] # Remove prefix. + _replace_toc(link.children, title, depth + 1) diff --git a/src/mkapi/templates/source.jinja2 b/src/mkapi/templates/source.jinja2 index a29a5ec4..826da134 100644 --- a/src/mkapi/templates/source.jinja2 +++ b/src/mkapi/templates/source.jinja2 @@ -1,8 +1,3 @@ ---- -search: - exclude: true ---- - {% if heading %}<{{ heading }} id="{{ obj.fullname }}" class="mkapi-heading" markdown="1">{% endif %} {{ fullname|safe }} {%- if heading %}{% endif %} diff --git a/src/mkapi/themes/mkapi-material.css b/src/mkapi/themes/mkapi-material.css index e73c44b5..140df1a4 100644 --- a/src/mkapi/themes/mkapi-material.css +++ b/src/mkapi/themes/mkapi-material.css @@ -107,6 +107,7 @@ li.mkapi-section-item li { font-size: 85%; } .mkapi-docs-link { + font-family: var(--md-text-font-family); display: block; float: right; } diff --git a/tests/test_objects.py b/tests/test_objects.py index 274ba187..c0fbd1a9 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -7,6 +7,7 @@ import pytest from mkapi.ast import iter_child_nodes +from mkapi.items import Attributes from mkapi.objects import ( LINK_PATTERN, Class, @@ -18,7 +19,7 @@ merge_items, objects, ) -from mkapi.utils import get_by_name, get_module_node +from mkapi.utils import get_by_name, get_by_type, get_module_node @pytest.fixture(scope="module") @@ -139,6 +140,51 @@ def test_kind(): assert attr.kind == "attribute" +def test_attribute_comment(): + src = ''' + """Module. + + Attributes: + a + b + """ + a: float #: Doc comment *inline* with attribute. + c: int #: C + class A: + attr0: int #: Doc comment *inline* with attribute. + #: list(str): Doc comment *before* attribute, with type specified. + attr1: list[str] + attr2 = 1 + attr3 = [1] #: list(int): Doc comment *inline* with attribute. + attr4: str + """Docstring *after* attribute, with type specified.""" + attr5: float + ''' + src = inspect.cleandoc(src) + node = ast.parse(src) + module = create_module("a", node, src) + t = module.attributes[0].doc.text.str + assert t == "Doc comment *inline* with attribute." + a = module.classes[0].attributes + assert a[0].doc.text.str == "Doc comment *inline* with attribute." + assert a[1].doc.text.str + assert a[1].doc.text.str.startswith("Doc comment *before* attribute, with") + assert isinstance(a[1].type.expr, ast.Subscript) + assert a[2].doc.text.str is None + assert a[3].doc.text.str == "Doc comment *inline* with attribute." + assert isinstance(a[3].type.expr, ast.Subscript) + assert a[4].doc.text.str == "Docstring *after* attribute, with type specified." + assert a[5].doc.text.str is None + section = get_by_type(module.doc.sections, Attributes) + assert section + a = section.items[0] + assert a.name == "a" + assert a.doc.text.str == "Doc comment *inline* with attribute." + a = section.items[2] + assert a.name == "c" + assert a.doc.text.str == "C" + + def test_merge_items(): """'''test''' def f(x: int = 0, y: str = 's') -> bool: From 1a8d48e4acac26ff467800b94dec25a326f09605 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 3 Feb 2024 22:18:13 +0900 Subject: [PATCH 137/148] container --- README.md | 61 ++++++++++--------- config.py | 9 ++- docs/1.md | 9 --- docs/2.md | 7 --- docs/index.md | 92 ++++++++++++++++++++++++++++- docs/usage/embed.md | 59 ++++++++++++++++++ docs/usage/styles.md | 7 +++ mkdocs.yml | 26 ++++---- src/mkapi/importlib.py | 8 +++ src/mkapi/pages.py | 3 + src/mkapi/plugins.py | 6 +- src/mkapi/renderers.py | 2 +- src/mkapi/templates/object.jinja2 | 4 +- src/mkapi/templates/source.jinja2 | 7 ++- src/mkapi/themes/mkapi-material.css | 3 +- tests/examples/styles/__init__.py | 9 +-- 16 files changed, 244 insertions(+), 68 deletions(-) delete mode 100644 docs/1.md delete mode 100644 docs/2.md create mode 100644 docs/usage/embed.md create mode 100644 docs/usage/styles.md diff --git a/README.md b/README.md index 1e90411b..4556c46a 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,27 @@ [![Python versions][pyversions-image]][pyversions-link] [![Code style: black][black-image]][black-link] -MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkAPI supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). +MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates +API documentation for Python code. + +MkAPI supports two styles of docstrings: +[Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and +[NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). Features of MkAPI are: -* **Type annotation**: If you write your function such as `def func(x: int) -> str:`, you don't need write type(s) in Parameters, Returns, or Yields section again. You can overwrite the type annotation in the corresponding docstring. -* **Object type inspection**: MkAPI plugin creates *class*, *dataclass*, *function*, or *generator* prefix for each object. -* **Attribute inspection**: If you write attributes with description as comment in module or class, Attributes section is automatically created. -* **Docstring inheritance**: Docstring of a subclass can inherit parameters and attributes description from its superclasses. -* **Table of Contents**: Table of contents are inserted into the documentation of each package, module, and class. -* **Page mode**: Comprehensive API documentation for your project, in which objects are linked to each other by type annotation. -* **Bidirectional Link**: Using the Page mode, bidirectional links are created between documentation and source code. +* **Type annotation**: If you write your function such as + `def func(x: int) -> str:`, you don't need write type(s) + in Parameters, Returns, or Yields section again. + You can overwrite the type annotation in the corresponding docstring. +* **Object type inspection**: MkAPI plugin creates *class*, + *dataclass*, *function*, *method*, *property* prefix for each object. +* **Docstring inheritance**: Docstring of a subclass can inherit parameters + and attributes description from its superclasses. +* **Table of Contents**: Table of contents are inserted into the documentation + of each package, module, and class. +* **Bidirectional Link**: Bidirectional links are created between + documentation and source code. ## Installation @@ -30,55 +40,50 @@ Add the following lines to `mkdocs.yml`: ~~~yml plugins: - - search # necessary for search to work - mkapi ~~~ ## Usage -MkAPI provides two modes to generate API documentation: Embedding mode and Page mode. +MkAPI provides two modes to generate API documentation: +Embedding mode and Page mode. ### Embedding Mode -To generate the API documentation in a Markdown source, add an exclamation mark (!), followed by `mkapi` in brackets, and the object full name in parentheses. Yes, this is like adding an image. The object can be a function, class, or module. +To generate the API documentation in a Markdown source, +add three colons + object full name. +The object can be a function, class, attribute, or module. ~~~markdown -![mkapi]() +::: package.module.object ~~~ You can combine this syntax with Markdown heading: ~~~markdown -## ![mkapi]() +## ::: package.module.object ~~~ -MkAPI imports objects that you specify. If they aren't in the `sys.path`, configure `mkdocs.yml` like below: - -~~~yml -plugins: - - search - - mkapi: - src_dirs: [, , ...] -~~~ - -Here, `pathX`s are inserted to `sys.path`. These `pathX`s must be relative to the `mkdocs.yml` directory. - -The embedding mode is useful to embed an object interface in an arbitrary position of a Markdown source. For more details, see: +The embedding mode is useful to embed an object interface +in an arbitrary position of a Markdown source. For more details, see: * [Google style examples](https://mkapi.daizutabi.net/examples/google_style) * [NumPy style examples](https://mkapi.daizutabi.net/examples/numpy_style) ### Page Mode -Using the page mode, you can construct a comprehensive API documentation for your project. You can get this powerful feature by just one line: +Using the page mode, you can construct a comprehensive API documentation +for your project. +You can get this powerful feature by just one line: ~~~yaml nav: - index.md - - API: mkapi/api/mkapi + - API: /mkapi.*** ~~~ -For more details, see [Page Mode and Internal Links](https://mkapi.daizutabi.net/usage/page) +For more details, see +[Page mode and internal links](https://mkapi.daizutabi.net/usage/page) [pypi-image]: https://badge.fury.io/py/mkapi.svg [pypi-link]: https://pypi.org/project/mkapi diff --git a/config.py b/config.py index aee36943..401cdf7f 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """Config functions.""" from __future__ import annotations +import sys from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -9,7 +10,13 @@ from mkapi.plugins import MkAPIPlugin -def on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 +def before_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 + """Called before `on_config` event of MkAPI plugin.""" + if "." not in sys.path: + sys.path.insert(0, "tests") + + +def after_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: # noqa: ARG001 """Called after `on_config` event of MkAPI plugin.""" diff --git a/docs/1.md b/docs/1.md deleted file mode 100644 index 5e315ff4..00000000 --- a/docs/1.md +++ /dev/null @@ -1,9 +0,0 @@ -# 31 - -## \_\_a\_\_.\_\_b\_\_ {#\_\_b\_\_} - -## ab - -dea - -::: polars.datafram diff --git a/docs/2.md b/docs/2.md deleted file mode 100644 index 839c1e48..00000000 --- a/docs/2.md +++ /dev/null @@ -1,7 +0,0 @@ -# 2 - -## 2-1 - -## 2-2 - -## 2-3 diff --git a/docs/index.md b/docs/index.md index 78e397db..10a468a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,93 @@ # Home -```python -def f(x, y=2): - pass +[![PyPI version][pypi-image]][pypi-link] +[![Python versions][pyversions-image]][pyversions-link] +[![Code style: black][black-image]][black-link] +MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates +API documentation for Python code. + +MkAPI supports two styles of docstrings: +[Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and +[NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). + +Features of MkAPI are: + +* **Type annotation**: If you write your function such as + `def func(x: int) -> str:`, you don't need write type(s) + in Parameters, Returns, or Yields section again. + You can overwrite the type annotation in the corresponding docstring. +* **Object type inspection**: MkAPI plugin creates *class*, + *dataclass*, *function*, *method*, *property* prefix for each object. +* **Docstring inheritance**: Docstring of a subclass can inherit parameters + and attributes description from its superclasses. +* **Table of Contents**: Table of contents are inserted into the documentation + of each package, module, and class. +* **Bidirectional Link**: Bidirectional links are created between + documentation and source code. + +## Installation + +Install the MkAPI plugin using pip: + +```bash +pip install mkapi +``` + +## Configuration + +Add the following lines to `mkdocs.yml`: + +```yaml +plugins: + - mkapi ``` + +## Usage + +MkAPI provides two modes to generate API documentation: +Embedding mode and Page mode. + +### Embedding Mode + +To generate the API documentation in a Markdown source, +add three colons + object full name. +The object can be a function, class, attribute, or module. + +```markdown +::: __mkapi__.package.module.object +``` + +You can combine this syntax with Markdown heading. + +```markdown +## ::: __mkapi__.package.module.object +``` + +The embedding mode is useful to embed an object interface +in an arbitrary position of a Markdown source. For more details, see: + +* [Google style examples](https://mkapi.daizutabi.net/examples/google_style) +* [NumPy style examples](https://mkapi.daizutabi.net/examples/numpy_style) + +### Page Mode + +Using the page mode, you can construct a comprehensive API documentation +for your project. +You can get this powerful feature by just one line: + +```yaml +nav: + - index.md + - API: /mkapi.*** +``` + +For more details, see +[Page mode and internal links](https://mkapi.daizutabi.net/usage/page) + +[pypi-image]: https://badge.fury.io/py/mkapi.svg +[pypi-link]: https://pypi.org/project/mkapi +[black-image]: https://img.shields.io/badge/code%20style-black-000000.svg +[black-link]: https://github.com/ambv/black +[pyversions-image]: https://img.shields.io/pypi/pyversions/mkapi.svg +[pyversions-link]: https://pypi.org/project/mkapi diff --git a/docs/usage/embed.md b/docs/usage/embed.md new file mode 100644 index 00000000..175713f2 --- /dev/null +++ b/docs/usage/embed.md @@ -0,0 +1,59 @@ +# Embedding mode + +## Example package + + + +[Example Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google) + +[Example NumPy Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy) + +``` sh +examples/ +├─ __init__.py +└─ styles/ + ├─ __init__.py + ├─ example_google.py + └─ example_numpy.py +``` + +## Package + +```markdown +::: __mkapi__.examples +``` + +::: examples + +!!! note + In the above example, green dashed border lines are just guide + for the eye to show the region of the documentation generated + by MkAPI. + +```markdown +::: __mkapi__.examples|source|bare +``` + +::: examples|source|bare + +```markdown +::: __mkapi__.examples.styles +``` + +## Package with \_\_all\_\_ + +::: examples.styles + +::: examples.styles|source|bare + +## Module + +```markdown +::: __mkapi__.examples.styles.example_google +``` + +::: examples.styles.example_google diff --git a/docs/usage/styles.md b/docs/usage/styles.md new file mode 100644 index 00000000..bf8f6200 --- /dev/null +++ b/docs/usage/styles.md @@ -0,0 +1,7 @@ +# Styles + +MkAPI supports two styles of docstrings: +[Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and +[NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). +See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/) documentations for +details. diff --git a/mkdocs.yml b/mkdocs.yml index a8a6f5d9..e45a840b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,12 +3,15 @@ site_url: https://daizutabi.github.io/mkapi/ site_description: API documentation with MkDocs. site_author: daizutabi repo_url: https://github.com/daizutabi/mkapi/ -edit_uri: "" +repo_name: daizutabi/mkapi +edit_uri: edit/main/docs/ theme: name: material font: text: Fira Sans code: Fira Code + icon: + repo: fontawesome/brands/github palette: - scheme: default primary: indigo @@ -24,7 +27,9 @@ theme: name: Switch to light mode features: - content.tooltips + - header.autohide - navigation.expand + - navigation.footer - navigation.indexes - navigation.instant - navigation.sections @@ -43,15 +48,16 @@ plugins: debug: true # on_config: custom.on_config markdown_extensions: - - pymdownx.arithmatex - pymdownx.magiclink + - pymdownx.highlight: + use_pygments: true + - pymdownx.inlinehilite + - pymdownx.superfences nav: - index.md - # - /mkapi.objects|nav_filter1|nav_filter2 - - Section: - - 1.md - # - /mkapi.**|nav_filter3 - - 2.md - - Schemdraw: /schemdraw.*** - - Polars: /polars.*** - - Altair: /altair.*** \ No newline at end of file + - Getting started: + - usage/styles.md + - usage/embed.md + - Schemdraw: /schemdraw.logic.*** + # - Polars: /polars.*** + # - Altair: /altair.*** \ No newline at end of file diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 91fdb77a..4726132b 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -130,6 +130,8 @@ def add_sections(module: Module) -> None: def add_classes(obj: Module | Class) -> None: """Add classes section.""" + if get_by_name(obj.doc.sections, "Classes"): + return if items := list(_iter_items(obj.classes)): section = Section("Classes", Type(), Text(), items) obj.doc.sections.append(section) @@ -137,6 +139,8 @@ def add_classes(obj: Module | Class) -> None: def add_functions(obj: Module | Class | Function) -> None: """Add functions section.""" + if get_by_name(obj.doc.sections, "Functions"): + return if items := list(_iter_items(obj.functions)): name = "Methods" if isinstance(obj, Class) else "Functions" section = Section(name, Type(), Text(), items) @@ -147,6 +151,8 @@ def add_attributes(obj: Module | Class) -> None: """Add attributes section.""" if get_by_type(obj.doc.sections, Attributes): return + if get_by_name(obj.doc.sections, "Attributes"): + return if items := list(_iter_items(obj.attributes)): section = Section("Attributes", Type(), Text(), items) obj.doc.sections.append(section) @@ -181,6 +187,8 @@ def add_sections_for_package(module: Module) -> None: (attributes, "Attributes"), ] for items, name in it: + if get_by_name(module.doc.sections, name): + continue if items: section = Section(name, Type(), Text(), items) module.doc.sections.append(section) diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py index 8a77c986..9f79dadf 100644 --- a/src/mkapi/pages.py +++ b/src/mkapi/pages.py @@ -121,6 +121,9 @@ def _split_markdown(source: str) -> Iterator[tuple[str, int, list[str]]]: yield markdown, -1, [] cursor = end heading, name = match.groups() + if name.startswith("__mkapi__."): + yield match.group().replace("__mkapi__.", ""), -1, [] + continue level = len(heading) name, filters = split_filters(name) yield name, level, filters diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index 395f938c..029c1218 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -65,11 +65,13 @@ class MkAPIPlugin(BasePlugin[MkAPIConfig]): api_uri_width: ClassVar[int] = 0 def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig: + if before_on_config := _get_function("before_on_config", self): + before_on_config(config, self) _update_templates(config, self) _update_config(config, self) _update_extensions(config, self) - if on_config := _get_function("on_config", self): - on_config(config, self) + if after_on_config := _get_function("after_on_config", self): + after_on_config(config, self) return config def on_files(self, files: Files, config: MkDocsConfig, **kwargs) -> Files: diff --git a/src/mkapi/renderers.py b/src/mkapi/renderers.py index 9fe55ed0..acdbf0b0 100644 --- a/src/mkapi/renderers.py +++ b/src/mkapi/renderers.py @@ -31,7 +31,7 @@ def render( filters: list[str], ) -> str: """Return a rendered Markdown.""" - heading = f"h{level}" if level else "" + heading = f"h{level}" if level else "p" prefix = obj.doc.type.markdown.split("..") self = obj.name.split(".")[-1].replace("_", "\\_") fullname = ".".join(prefix[:-1] + [self]) diff --git a/src/mkapi/templates/object.jinja2 b/src/mkapi/templates/object.jinja2 index 0bdf2fb6..82df2374 100644 --- a/src/mkapi/templates/object.jinja2 +++ b/src/mkapi/templates/object.jinja2 @@ -1,3 +1,4 @@ +
    {% if heading %}<{{ heading }} id="{{ obj.fullname }}" class="mkapi-heading" markdown="1">{% endif %} {{ fullname|safe }} {%- if "sourcelink" in filters %} @@ -70,4 +71,5 @@ Bases : {% if section.items %}{% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} +
    \ No newline at end of file diff --git a/src/mkapi/templates/source.jinja2 b/src/mkapi/templates/source.jinja2 index 826da134..039d65f8 100644 --- a/src/mkapi/templates/source.jinja2 +++ b/src/mkapi/templates/source.jinja2 @@ -1,3 +1,5 @@ +
    +{% if "bare" not in filters %} {% if heading %}<{{ heading }} id="{{ obj.fullname }}" class="mkapi-heading" markdown="1">{% endif %} {{ fullname|safe }} {%- if heading %}{% endif %} @@ -11,7 +13,10 @@ {%- endfor -%}

    +{% endif %} ``` {.python .mkapi-source} {{ source|safe }} -``` \ No newline at end of file +``` + +
    \ No newline at end of file diff --git a/src/mkapi/themes/mkapi-material.css b/src/mkapi/themes/mkapi-material.css index 140df1a4..12b9c65a 100644 --- a/src/mkapi/themes/mkapi-material.css +++ b/src/mkapi/themes/mkapi-material.css @@ -2,7 +2,8 @@ h1.mkapi-heading, h2.mkapi-heading, h3.mkapi-heading, h4.mkapi-heading, -h5.mkapi-heading { +h5.mkapi-heading, +p.mkapi-heading { font-size: 85%; font-weight: 400; text-align-last: justify; diff --git a/tests/examples/styles/__init__.py b/tests/examples/styles/__init__.py index 5e75222f..e3435f7f 100644 --- a/tests/examples/styles/__init__.py +++ b/tests/examples/styles/__init__.py @@ -1,4 +1,5 @@ -"""" -http://ja.dochub.org/sphinx/usage/extensions/example_google.html#example-google -http://ja.dochub.org/sphinx/usage/extensions/example_numpy.html#example-numpy -""" +"""Example module for MkAPI test.""" +from .example_google import ExampleClass as ExampleClassGoogle +from .example_numpy import ExampleClass as ExampleClassNumPy + +__all__ = ["ExampleClassGoogle", "ExampleClassNumPy"] \ No newline at end of file From 53e98ec31b7bb25d5cd5447836e534c1a8c8c3fd Mon Sep 17 00:00:00 2001 From: daizutabi Date: Sat, 3 Feb 2024 23:58:03 +0900 Subject: [PATCH 138/148] object mode --- README.md | 4 +- docs/api/examples/README.md | 1 + docs/api/examples/styles/README.md | 1 + docs/api/examples/styles/example_google.md | 33 +++++++++ docs/api/examples/styles/example_numpy.md | 27 +++++++ docs/index.md | 28 +++----- docs/src/examples.md | 1 + docs/src/examples/styles.md | 1 + docs/src/examples/styles/example_google.md | 1 + docs/src/examples/styles/example_numpy.md | 1 + docs/usage/{embed.md => object.md} | 41 +++++++++-- docs/usage/page.md | 83 ++++++++++++++++++++++ docs/usage/styles.md | 7 -- mkdocs.yml | 14 ++-- src/mkapi/objects.py | 40 +++++++---- tests/test_objects.py | 14 ++-- 16 files changed, 235 insertions(+), 62 deletions(-) create mode 100644 docs/api/examples/README.md create mode 100644 docs/api/examples/styles/README.md create mode 100644 docs/api/examples/styles/example_google.md create mode 100644 docs/api/examples/styles/example_numpy.md create mode 100644 docs/src/examples.md create mode 100644 docs/src/examples/styles.md create mode 100644 docs/src/examples/styles/example_google.md create mode 100644 docs/src/examples/styles/example_numpy.md rename docs/usage/{embed.md => object.md} (54%) create mode 100644 docs/usage/page.md delete mode 100644 docs/usage/styles.md diff --git a/README.md b/README.md index 4556c46a..189ca872 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ plugins: ## Usage MkAPI provides two modes to generate API documentation: -Embedding mode and Page mode. +Object mode and Page mode. -### Embedding Mode +### Object Mode To generate the API documentation in a Markdown source, add three colons + object full name. diff --git a/docs/api/examples/README.md b/docs/api/examples/README.md new file mode 100644 index 00000000..749333de --- /dev/null +++ b/docs/api/examples/README.md @@ -0,0 +1 @@ +# ::: examples|sourcelink diff --git a/docs/api/examples/styles/README.md b/docs/api/examples/styles/README.md new file mode 100644 index 00000000..6529a217 --- /dev/null +++ b/docs/api/examples/styles/README.md @@ -0,0 +1 @@ +# ::: examples.styles|sourcelink diff --git a/docs/api/examples/styles/example_google.md b/docs/api/examples/styles/example_google.md new file mode 100644 index 00000000..e44ca0b4 --- /dev/null +++ b/docs/api/examples/styles/example_google.md @@ -0,0 +1,33 @@ +# ::: examples.styles.example_google|sourcelink + +## ::: examples.styles.example_google.ExampleError|sourcelink + +## ::: examples.styles.example_google.ExampleClass|sourcelink + +### ::: examples.styles.example_google.ExampleClass.example_method|sourcelink + +### ::: examples.styles.example_google.ExampleClass.__special__|sourcelink + +### ::: examples.styles.example_google.ExampleClass._private|sourcelink + +### ::: examples.styles.example_google.ExampleClass.readonly_property|sourcelink + +### ::: examples.styles.example_google.ExampleClass.readwrite_property|sourcelink + +## ::: examples.styles.example_google.ExamplePEP526Class|sourcelink + +### ::: examples.styles.example_google.ExamplePEP526Class.attr1|sourcelink + +### ::: examples.styles.example_google.ExamplePEP526Class.attr2|sourcelink + +## ::: examples.styles.example_google.function_with_types_in_docstring|sourcelink + +## ::: examples.styles.example_google.function_with_pep484_type_annotations|sourcelink + +## ::: examples.styles.example_google.module_level_function|sourcelink + +## ::: examples.styles.example_google.example_generator|sourcelink + +## ::: examples.styles.example_google.module_level_variable1|sourcelink + +## ::: examples.styles.example_google.module_level_variable2|sourcelink diff --git a/docs/api/examples/styles/example_numpy.md b/docs/api/examples/styles/example_numpy.md new file mode 100644 index 00000000..1a91b5dc --- /dev/null +++ b/docs/api/examples/styles/example_numpy.md @@ -0,0 +1,27 @@ +# ::: examples.styles.example_numpy|sourcelink + +## ::: examples.styles.example_numpy.ExampleError|sourcelink + +## ::: examples.styles.example_numpy.ExampleClass|sourcelink + +### ::: examples.styles.example_numpy.ExampleClass.example_method|sourcelink + +### ::: examples.styles.example_numpy.ExampleClass.__special__|sourcelink + +### ::: examples.styles.example_numpy.ExampleClass._private|sourcelink + +### ::: examples.styles.example_numpy.ExampleClass.readonly_property|sourcelink + +### ::: examples.styles.example_numpy.ExampleClass.readwrite_property|sourcelink + +## ::: examples.styles.example_numpy.function_with_types_in_docstring|sourcelink + +## ::: examples.styles.example_numpy.function_with_pep484_type_annotations|sourcelink + +## ::: examples.styles.example_numpy.module_level_function|sourcelink + +## ::: examples.styles.example_numpy.example_generator|sourcelink + +## ::: examples.styles.example_numpy.module_level_variable1|sourcelink + +## ::: examples.styles.example_numpy.module_level_variable2|sourcelink diff --git a/docs/index.md b/docs/index.md index 10a468a8..4a459cc4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,13 @@ # Home -[![PyPI version][pypi-image]][pypi-link] -[![Python versions][pyversions-image]][pyversions-link] -[![Code style: black][black-image]][black-link] - MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates API documentation for Python code. MkAPI supports two styles of docstrings: [Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). +See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/) documentations for +details. Features of MkAPI are: @@ -46,9 +44,9 @@ plugins: ## Usage MkAPI provides two modes to generate API documentation: -Embedding mode and Page mode. +Object mode and Page mode. -### Embedding Mode +### Object Mode To generate the API documentation in a Markdown source, add three colons + object full name. @@ -65,10 +63,8 @@ You can combine this syntax with Markdown heading. ``` The embedding mode is useful to embed an object interface -in an arbitrary position of a Markdown source. For more details, see: - -* [Google style examples](https://mkapi.daizutabi.net/examples/google_style) -* [NumPy style examples](https://mkapi.daizutabi.net/examples/numpy_style) +in an arbitrary position of a Markdown source. For more details, see +[Object mode](usage/object.md). ### Page Mode @@ -79,15 +75,7 @@ You can get this powerful feature by just one line: ```yaml nav: - index.md - - API: /mkapi.*** + - API: /package.*** ``` -For more details, see -[Page mode and internal links](https://mkapi.daizutabi.net/usage/page) - -[pypi-image]: https://badge.fury.io/py/mkapi.svg -[pypi-link]: https://pypi.org/project/mkapi -[black-image]: https://img.shields.io/badge/code%20style-black-000000.svg -[black-link]: https://github.com/ambv/black -[pyversions-image]: https://img.shields.io/pypi/pyversions/mkapi.svg -[pyversions-link]: https://pypi.org/project/mkapi +For more details, see [Page mode](usage/page.md). diff --git a/docs/src/examples.md b/docs/src/examples.md new file mode 100644 index 00000000..d2a0d7e4 --- /dev/null +++ b/docs/src/examples.md @@ -0,0 +1 @@ +# ::: examples|source|__mkapi__:examples=0 diff --git a/docs/src/examples/styles.md b/docs/src/examples/styles.md new file mode 100644 index 00000000..1437b3e6 --- /dev/null +++ b/docs/src/examples/styles.md @@ -0,0 +1 @@ +# ::: examples.styles|source|__mkapi__:examples.styles=0|__mkapi__:examples.styles.__all__=4 diff --git a/docs/src/examples/styles/example_google.md b/docs/src/examples/styles/example_google.md new file mode 100644 index 00000000..349eaba8 --- /dev/null +++ b/docs/src/examples/styles/example_google.md @@ -0,0 +1 @@ +# ::: examples.styles.example_google|source|__mkapi__:examples.styles.example_google=0|__mkapi__:examples.styles.example_google.ExampleError=152|__mkapi__:examples.styles.example_google.ExampleClass=179|__mkapi__:examples.styles.example_google.ExampleClass.example_method=244|__mkapi__:examples.styles.example_google.ExampleClass.__special__=260|__mkapi__:examples.styles.example_google.ExampleClass.__special_without_docstring__=276|__mkapi__:examples.styles.example_google.ExampleClass._private=279|__mkapi__:examples.styles.example_google.ExampleClass._private_without_docstring=294|__mkapi__:examples.styles.example_google.ExampleClass.readonly_property=226|__mkapi__:examples.styles.example_google.ExampleClass.readwrite_property=231|__mkapi__:examples.styles.example_google.ExamplePEP526Class=297|__mkapi__:examples.styles.example_google.ExamplePEP526Class.attr1=312|__mkapi__:examples.styles.example_google.ExamplePEP526Class.attr2=313|__mkapi__:examples.styles.example_google.function_with_types_in_docstring=44|__mkapi__:examples.styles.example_google.function_with_pep484_type_annotations=64|__mkapi__:examples.styles.example_google.module_level_function=77|__mkapi__:examples.styles.example_google.example_generator=131|__mkapi__:examples.styles.example_google.module_level_variable1=34|__mkapi__:examples.styles.example_google.module_level_variable2=36 diff --git a/docs/src/examples/styles/example_numpy.md b/docs/src/examples/styles/example_numpy.md new file mode 100644 index 00000000..2d1e44bd --- /dev/null +++ b/docs/src/examples/styles/example_numpy.md @@ -0,0 +1 @@ +# ::: examples.styles.example_numpy|source|__mkapi__:examples.styles.example_numpy=0|__mkapi__:examples.styles.example_numpy.ExampleError=190|__mkapi__:examples.styles.example_numpy.ExampleClass=224|__mkapi__:examples.styles.example_numpy.ExampleClass.example_method=297|__mkapi__:examples.styles.example_numpy.ExampleClass.__special__=319|__mkapi__:examples.styles.example_numpy.ExampleClass.__special_without_docstring__=335|__mkapi__:examples.styles.example_numpy.ExampleClass._private=338|__mkapi__:examples.styles.example_numpy.ExampleClass._private_without_docstring=353|__mkapi__:examples.styles.example_numpy.ExampleClass.readonly_property=279|__mkapi__:examples.styles.example_numpy.ExampleClass.readwrite_property=284|__mkapi__:examples.styles.example_numpy.function_with_types_in_docstring=54|__mkapi__:examples.styles.example_numpy.function_with_pep484_type_annotations=79|__mkapi__:examples.styles.example_numpy.module_level_function=100|__mkapi__:examples.styles.example_numpy.example_generator=164|__mkapi__:examples.styles.example_numpy.module_level_variable1=44|__mkapi__:examples.styles.example_numpy.module_level_variable2=46 diff --git a/docs/usage/embed.md b/docs/usage/object.md similarity index 54% rename from docs/usage/embed.md rename to docs/usage/object.md index 175713f2..ab7d3af1 100644 --- a/docs/usage/embed.md +++ b/docs/usage/object.md @@ -1,6 +1,6 @@ -# Embedding mode +# Object mode -## Example package +## `examples` package +In this page, we use a demonstration package: `examples` +to describe the Object mode of MkAPI. +This package includes one subpackage `styles` +and the `styles` subpackage includes two modules: +`google.py` and `numpy.py` +These two modules are style guides of docstrings: -[Example Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google) +- [Example Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google) -[Example NumPy Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy) +- [Example NumPy Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy) + +The directory structure of `examples` package is shown below: ``` sh examples/ ├─ __init__.py └─ styles/ ├─ __init__.py - ├─ example_google.py - └─ example_numpy.py + ├─ google.py + └─ numpy.py ``` -## Package + + +## Top level Package + +First, let's see the top level package `examples`. +To embed the object documentation in a Markdown text, +you can write a Markdown syntax like: ```markdown ::: examples ``` +The three collon (`:::`) must start at the begging of line, +followed by a space (` `) and an object fullname, +for example, `package.module.function`. +Here, just a pakcage name `examples`. +MkAPI finds this syntax in Markdown files and convert it +to the corresponding object documentation: + ::: examples !!! note - In the above example, green dashed border lines are just guide - for the eye to show the region of the documentation generated - by MkAPI. + In the above example, the green dashed border + is just guide for the eye to show the region of + the documentation generated by MkAPI. + +In the above example, a horizontal gray line is an object +boundary to seperate successive objects. +A gray text above the line is the fullname +of the object. +Below the line, the object kind (*package*) and +(qualified) name (`examples`) are shown. +This part is a *heading* of documentation. + +Below the heading, main contents of documentation +are rendered. +In the `examples` package case, the contents is a just +one-line summary for the package. + +MkAPI can embed the source code of objects as well as their +documentation. For this, use *filters* like below: ```markdown ::: examples|source|bare ``` +In this case, two filters `source` and `bare` are used. + +- `source` ー Embed source code instead of documentation. +- `bare` ー Omit heading part so that only source code are shown. + +The output is shown below: + ::: examples|source|bare +The above docstring is the only content of `examples/__init__.py`. + ## Package with `__all__` +A pakcage can have an `__all__` attribute to provide names +that should be imported when `from package import *` is encountered. +(See "[Importing * From a Package][1].") + +[1]: +https://docs.python.org/3/tutorial/modules.html#importing-from-a-package> + +MkAPI recognize the `__all__` attribute and automatically +list up the objects categorized by its kind +(*module*, *class*, *function*, or *attribute*). + +In our example, `examples.styles` package have the `__all__` attribute. +Check the output: + ```markdown ::: examples.styles ``` ::: examples.styles +In the above example, `examples.styles` object documentation has +a **Classes** section that includes two classes: +`ExampleClassGoogle` and `ExampleClassNumpy`. +These names has a link to the object documentation to navigate you. +The summary line for a class is also shown for convinience. +Blow is the source code of `examples.styles/__init__.py`. + ::: examples.styles|source|bare +Two classes have a diffrent class with the same name of `ExampleClass`. +`examples.styles` uses `import` statement with alias name +(`ExampleClassGoogle` or `ExampleClassNumpy`) to distinct these +two classes. +The **Classes** section shows these alias names, but you can check +the unaliased fullname by hovering mouse cursor on the names. + +!!! Note + Currently, MkAPI doesn't support dynamic assignment to `__all__`. + For example, the below code are just ignored: + + ```python + def get_all(): + return ["a", "b", "c"] + + __all__ = get_all() + ``` + ## Module +Python module has classes, functions, or attributes as its members. +A Module documentation can be a docstring of module itself and members list. + ```markdown -::: examples.styles.example_google +::: examples.styles.google ``` -::: examples.styles.example_google +::: examples.styles.google !!! warning MkAPI doesn't support reStructuredText formatting. +You can check the correspoing docstring +[here][examples.styles.google|source]. + +!!! note + You can link Markdown text to object documentation or source: + + - `[here][examples.styles.google|source]` + ## Module members ### Class ```markdown -::: examples.styles.example_google.ExampleClass +::: examples.styles.google.ExampleClass ``` -::: examples.styles.example_google.ExampleClass +::: examples.styles.google.ExampleClass !!! warning "\_\_init\_\_" should be written in a inline code (\`\_\_init\_\_\`) @@ -78,15 +173,15 @@ examples/ ### Function ```markdown -::: examples.styles.example_google.module_level_function +::: examples.styles.google.module_level_function ``` -::: examples.styles.example_google.module_level_function +::: examples.styles.google.module_level_function ### Attribute ```markdown -::: examples.styles.example_google.module_level_variable2|sourcelink +::: examples.styles.google.module_level_variable2|sourcelink ``` -::: examples.styles.example_google.module_level_variable2|sourcelink +::: examples.styles.google.module_level_variable2|sourcelink diff --git a/mkdocs.yml b/mkdocs.yml index acb4ed84..cd9b9284 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,7 +27,6 @@ theme: name: Switch to light mode features: - content.tooltips - - header.autohide - navigation.expand - navigation.footer - navigation.indexes @@ -58,5 +57,5 @@ nav: # - API: /mkapi.*** - Examples: /examples.** # - Schemdraw: /schemdraw.*** - - Polars: /polars.*** + # - Polars: /polars.*** # - Altair: /altair.*** \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e91067ec..93a1b86c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ ignore = [ "COM812", "D105", "D406", + "G004", "D407", "EM102", "ERA001", diff --git a/src/mkapi/importlib.py b/src/mkapi/importlib.py index 83f60ca3..b3540714 100644 --- a/src/mkapi/importlib.py +++ b/src/mkapi/importlib.py @@ -120,7 +120,7 @@ def add_sections(module: Module) -> None: name = "Methods" if isinstance(obj, Class) else "Functions" add_section(obj, obj.functions, name) if isinstance(obj, Module | Class): - add_section(obj, obj.attributes, "Attributes") + add_section_attributes(obj) def add_section( @@ -136,6 +136,32 @@ def add_section( obj.doc.sections.append(section) +def add_section_attributes(obj: Module | Class) -> None: + """Add an Attributes section.""" + name = "Attributes" + if section := get_by_name(obj.doc.sections, name): + index = obj.doc.sections.index(section) + else: + index = -1 + items = [] + attributes = [] + for attr in obj.attributes: + if not is_empty(attr): + if attr.doc.sections: + items.append(_get_item(attr)) + else: + items.append(_get_item_attribute(attr)) + continue + attributes.append(attr) + obj.attributes = attributes + if items: + section = Section(name, Type(), Text(), items) + if index == -1: + obj.doc.sections.append(section) + else: + obj.doc.sections[index] = section + + ASNAME_PATTERN = re.compile(r"^\[.+?\]\[(__mkapi__\..+?)\]$") @@ -178,3 +204,7 @@ def _get_item(obj: Module | Class | Function | Attribute) -> Item: type_.markdown = type_.markdown.split("..")[-1] text.markdown = text.markdown.split("\n\n")[0] return Item("", type_, text) + + +def _get_item_attribute(attr: Attribute) -> Item: + return Item(attr.name, attr.type, attr.doc.text) diff --git a/src/mkapi/markdown.py b/src/mkapi/markdown.py index a5ab6afc..63fa2dfd 100644 --- a/src/mkapi/markdown.py +++ b/src/mkapi/markdown.py @@ -235,6 +235,9 @@ def convert(text: str) -> str: return "".join(_convert(text)) +INLINE_CODE = re.compile(r"(?P
    `+).+?(?P=pre)")
    +
    +
     def finditer(pattern: re.Pattern, text: str) -> Iterator[re.Match | str]:
         """Yield strings or match objects from a markdown text."""
         for match in _iter_fenced_codes(text):
    @@ -242,6 +245,10 @@ def finditer(pattern: re.Pattern, text: str) -> Iterator[re.Match | str]:
                 yield match.group()
             else:
                 yield from _iter(pattern, match)
    +            # for m in _iter(INLINE_CODE, match):
    +            #     if isinstance(m, re.Match):
    +            #         yield m.group()
    +            #     else:
     
     
     def sub(pattern: re.Pattern, rel: Callable[[re.Match], str], text: str) -> str:
    diff --git a/src/mkapi/objects.py b/src/mkapi/objects.py
    index 1233a496..2084c877 100644
    --- a/src/mkapi/objects.py
    +++ b/src/mkapi/objects.py
    @@ -115,7 +115,7 @@ def create_attribute(
                     _, lines[0] = lines[0].split(":", maxsplit=1)
                     doc.text.str = "\n".join(lines)
         else:
    -        doc = Docstring("", Type(), Text(), [])
    +        doc = Docstring("", Type(), assign.text, [])
         name, type_, default = assign.name, assign.type, assign.default
         return Attribute(name, node, doc, module, parent, type_, default)
     
    @@ -326,19 +326,23 @@ def merge_attributes(obj: Module | Class) -> None:
         """Merge attributes."""
         if not (section := get_by_type(obj.doc.sections, Assigns)):
             return
    -    index = obj.doc.sections.index(section)
    -    del obj.doc.sections[index]
    +    section.name = "Attributes"
         module = obj if isinstance(obj, Module) else obj.module
         parent = obj if isinstance(obj, Class) else None
    +    attributes = []
         for assign in section.items:
    -        if attr := get_by_name(obj.attributes, assign.name):
    +        if not (attr := get_by_name(obj.attributes, assign.name)):
    +            attr = create_attribute(assign, module, parent)
    +        else:
                 if not attr.doc.text.str:
                     attr.doc.text.str = assign.text.str
                 if not attr.type.expr:
                     attr.type.expr = assign.type.expr
    -        else:
    -            attr = create_attribute(assign, module, parent)
    -            obj.attributes.append(attr)
    +        attributes.append(attr)
    +    for attr in obj.attributes:
    +        if not get_by_name(attributes, attr.name):
    +            attributes.append(attr)
    +    obj.attributes = attributes
     
     
     def merge_bases(obj: Class) -> None:
    diff --git a/src/mkapi/pages.py b/src/mkapi/pages.py
    index df57f69c..c55ace47 100644
    --- a/src/mkapi/pages.py
    +++ b/src/mkapi/pages.py
    @@ -162,8 +162,9 @@ def replace_object(match: re.Match) -> str:
         #         markdown = create_markdown(name, level, updated_filters)
         #         markdowns.append(markdown)
         # markdown = "\n\n".join(markdowns)
    +    # return re.sub(LINK_PATTERN, replace_link, markdown)
         replace_link = partial(_replace_link, directory=Path(path).parent, anchor=anchor)
    -    return re.sub(LINK_PATTERN, replace_link, markdown)
    +    return mkapi.markdown.sub(LINK_PATTERN, replace_link, markdown)
     
     
     def create_markdown(name: str, level: int, filters: list[str]) -> str:
    @@ -174,11 +175,12 @@ def create_markdown(name: str, level: int, filters: list[str]) -> str:
         return mkapi.renderers.render(obj, level, filters)
     
     
    -LINK_PATTERN = re.compile(r"\[([^[\]\s]+?)\]\[([^[\]\s]+?)\]")
    +LINK_PATTERN = re.compile(r"(? str:
         asname, fullname = match.groups()
    +    fullname, filters = split_filters(fullname)
         if fullname.startswith("__mkapi__.__source__."):
             fullname = fullname[21:]
             if source_path := source_paths.get(fullname):
    @@ -186,6 +188,12 @@ def _replace_link(match: re.Match, directory: Path, anchor: str = "source") -> s
                 return f'[[{anchor}]]({uri}#{fullname} "{fullname}")'
             return ""
     
    +    if "source" in filters:
    +        if source_path := source_paths.get(fullname):
    +            uri = source_path.relative_to(directory, walk_up=True).as_posix()
    +            return f'[{asname}]({uri}#{fullname} "{fullname}")'
    +        return asname
    +
         if fullname.startswith("__mkapi__."):
             from_mkapi = True
             fullname = fullname[10:]
    diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py
    index fc3a5cf0..afc087a6 100644
    --- a/src/mkapi/plugins.py
    +++ b/src/mkapi/plugins.py
    @@ -142,8 +142,7 @@ def on_serve(self, server, config: MkDocsConfig, builder, **kwargs):
         def on_shutdown(self) -> None:
             for path in MkAPIPlugin.api_dirs:
                 if path.exists():
    -                msg = f"Removing API directory: {path}"
    -                logger.info(msg)
    +                logger.info(f"Deleting API directory: {path}")
                     shutil.rmtree(path)
     
     
    @@ -191,9 +190,14 @@ def _create_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None:
         def mkdir(path: str) -> list:
             api_dir = Path(config.docs_dir) / path
             if api_dir.exists() and api_dir not in MkAPIPlugin.api_dirs:
    -            msg = f"API directory exists: {api_dir}"
    -            logger.error(msg)
    -            sys.exit()
    +            logger.warning(f"API directory exists: {api_dir}")
    +            ans = input("Delete the directory? [yes/no] ")
    +            if ans.lower() == "yes":
    +                logger.info(f"Deleting API directory: {api_dir}")
    +                shutil.rmtree(api_dir)
    +            else:
    +                logger.error("Delete the directory manually.")
    +                sys.exit()
             if not api_dir.exists():
                 msg = f"Making API directory: {api_dir}"
                 logger.info(msg)
    diff --git a/src/mkapi/templates/object.jinja2 b/src/mkapi/templates/object.jinja2
    index 82df2374..1195e4f7 100644
    --- a/src/mkapi/templates/object.jinja2
    +++ b/src/mkapi/templates/object.jinja2
    @@ -56,7 +56,7 @@ Bases :
     {% for item in section.items %}
     
  • {%- if item.name -%} -{{ item.name }} +{{ item.name.replace("_", "\\_") }} {%- endif -%} {%- if item.name and item.type.markdown %} : {% endif %} {%- if item.type.markdown -%} diff --git a/tests/docstrings/conftest.py b/tests/docstrings/conftest.py index 206b62bb..44d62e40 100644 --- a/tests/docstrings/conftest.py +++ b/tests/docstrings/conftest.py @@ -17,12 +17,12 @@ def load_module(name): @pytest.fixture(scope="module") def google(): - return load_module("examples.styles.example_google") + return load_module("examples.styles.google") @pytest.fixture(scope="module") def numpy(): - return load_module("examples.styles.example_numpy") + return load_module("examples.styles.numpy") @pytest.fixture(scope="module") diff --git a/tests/examples/styles/__init__.py b/tests/examples/styles/__init__.py index e3435f7f..d3c10d6b 100644 --- a/tests/examples/styles/__init__.py +++ b/tests/examples/styles/__init__.py @@ -1,5 +1,5 @@ """Example module for MkAPI test.""" -from .example_google import ExampleClass as ExampleClassGoogle -from .example_numpy import ExampleClass as ExampleClassNumPy +from .google import ExampleClass as ExampleClassGoogle +from .numpy import ExampleClass as ExampleClassNumPy -__all__ = ["ExampleClassGoogle", "ExampleClassNumPy"] \ No newline at end of file +__all__ = ["ExampleClassGoogle", "ExampleClassNumPy"] diff --git a/tests/examples/styles/example_google.py b/tests/examples/styles/google.py similarity index 97% rename from tests/examples/styles/example_google.py rename to tests/examples/styles/google.py index 5fde6e22..4886103f 100644 --- a/tests/examples/styles/example_google.py +++ b/tests/examples/styles/google.py @@ -9,7 +9,7 @@ sections. Sections support any reStructuredText formatting, including literal blocks:: - $ python example_google.py + $ python google.py Section breaks are created by resuming unindented text. Section breaks are also implicitly created anytime a new section starts. @@ -125,7 +125,7 @@ def module_level_function(param1, param2=None, *args, **kwargs): """ if param1 == param2: - raise ValueError('param1 may not be equal to param2') + raise ValueError("param1 may not be equal to param2") return True @@ -218,7 +218,7 @@ def __init__(self, param1, param2, param3): self.attr3 = param3 #: Doc comment *inline* with attribute #: list(str): Doc comment *before* attribute, with type specified - self.attr4 = ['attr4'] + self.attr4 = ["attr4"] self.attr5 = None """str: Docstring *after* attribute, with type specified.""" @@ -226,7 +226,7 @@ def __init__(self, param1, param2, param3): @property def readonly_property(self): """str: Properties should be documented in their getter method.""" - return 'readonly_property' + return "readonly_property" @property def readwrite_property(self): @@ -236,7 +236,7 @@ def readwrite_property(self): If the setter method contains notable behavior, it should be mentioned here. """ - return ['readwrite_property'] + return ["readwrite_property"] @readwrite_property.setter def readwrite_property(self, value): @@ -272,7 +272,6 @@ def __special__(self): napoleon_include_special_with_doc = True """ - pass def __special_without_docstring__(self): pass @@ -290,11 +289,11 @@ def _private(self): napoleon_include_private_with_doc = True """ - pass def _private_without_docstring(self): pass + class ExamplePEP526Class: """The summary line for a class docstring should fit on one line. @@ -311,4 +310,4 @@ class ExamplePEP526Class: """ attr1: str - attr2: int \ No newline at end of file + attr2: int diff --git a/tests/examples/styles/example_numpy.py b/tests/examples/styles/numpy.py similarity index 98% rename from tests/examples/styles/example_numpy.py rename to tests/examples/styles/numpy.py index 2712447f..0c50f88e 100644 --- a/tests/examples/styles/example_numpy.py +++ b/tests/examples/styles/numpy.py @@ -10,7 +10,7 @@ sections. Sections support any reStructuredText formatting, including literal blocks:: - $ python example_numpy.py + $ python numpy.py Section breaks are created with two blank lines. Section breaks are also @@ -158,7 +158,7 @@ def module_level_function(param1, param2=None, *args, **kwargs): """ if param1 == param2: - raise ValueError('param1 may not be equal to param2') + raise ValueError("param1 may not be equal to param2") return True @@ -331,7 +331,6 @@ def __special__(self): napoleon_include_special_with_doc = True """ - pass def __special_without_docstring__(self): pass @@ -349,7 +348,6 @@ def _private(self): napoleon_include_private_with_doc = True """ - pass def _private_without_docstring(self): pass diff --git a/tests/test_items.py b/tests/test_items.py index e39f555b..1c4feb60 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -120,7 +120,7 @@ def load_module(name): @pytest.fixture(scope="module") def google(): - return load_module("examples.styles.example_google") + return load_module("examples.styles.google") @pytest.fixture(scope="module") diff --git a/tests/test_objects.py b/tests/test_objects.py index 9ffcf5dc..36e75eb0 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -26,7 +26,7 @@ def google(): path = str(Path(__file__).parent) if path not in sys.path: sys.path.insert(0, str(path)) - return get_module_node("examples.styles.example_google") + return get_module_node("examples.styles.google") @pytest.fixture(scope="module") From 3459144be154fcaa1f3787e4deb23d1d60b84ab5 Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 7 Feb 2024 19:50:51 +0900 Subject: [PATCH 146/148] docs --- docs/index.md | 12 +- docs/stylesheets/extra.css | 6 + docs/usage/config.md | 126 ++++++++++++++++ docs/usage/object.md | 107 +++++++++---- docs/usage/page.md | 224 ++++++++++++++++++++-------- mkdocs.yml | 15 +- src/mkapi/plugins.py | 4 +- src/mkapi/themes/mkapi-material.css | 6 +- tests/test_objects.py | 5 + 9 files changed, 403 insertions(+), 102 deletions(-) create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/usage/config.md diff --git a/docs/index.md b/docs/index.md index a18a5c21..e54170a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,16 @@ # Home -MkAPI plugin for [MkDocs](https://www.mkdocs.org/) generates +[![Built with Material for MkDocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?style=for-the-badge&logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/) + +MkAPI is a plugin for [MkDocs](https://www.mkdocs.org/) to generate a API documentation for your Python project. MkAPI supports two styles of docstrings: -[Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) and +[Google](http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) +and [NumPy](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). -See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/) documentations for +See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/) +documentations for details. Features of MkAPI are: @@ -14,7 +18,7 @@ Features of MkAPI are: * **Type annotation**: If you write your function such as `def func(x: int) -> str:`, you don't need write type(s) in Parameters, Returns, or Yields section again. - You can overwrite the type annotation in the corresponding docstring. + You can override the type annotation in the corresponding docstring. * **Object type inspection**: MkAPI plugin creates *class*, *dataclass*, *function*, *method*, *property* prefix for each object. * **Docstring inheritance**: Docstring of a subclass can inherit parameters diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..9d06e86a --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,6 @@ +.mkapi-example-output code { + font-family: monospace; +} +.mkapi-source code { + font-family: monospace; +} \ No newline at end of file diff --git a/docs/usage/config.md b/docs/usage/config.md new file mode 100644 index 00000000..530b3044 --- /dev/null +++ b/docs/usage/config.md @@ -0,0 +1,126 @@ +# Configuration + +## Exclude modules + +You can skip generation of documentation for some +specific modules with the plugin's `exclude` setting: + +```yaml title="mkdocs.yml" +plugins: + - mkapi: + exclude: + - altair.vegalite +``` + +For example, in the above setting, +`altair.vegalite` package and its submodules are excluded. +This feature is useful to skip test modules, +unnecessary modules, or huge modules. + +## Configuration script + +You can customize the plugin behaviors with +the plugin's `config` setting: + +```yaml title="mkdocs.yml" +plugins: + - mkapi: + config: config.py +``` + +`config.py` script file should be located in the same +directory of `mkdocs.yml` like below: + +``` sh +. +├─ docs/ +│ └─ index.md +├─ config.py +└─ mkdocs.yml +``` + +!!! Note + - You can chage the script name. + - If the config file is a module and importable, + you can write as `config: modulename` + without `.py` extension. + +Currently, five funtions can be called from MkAPI plugin. +You can define your own functions to customize plugin behaviors +or Navigation title for section, page, and/or toc. + +```python title="config.py" +"""Config functions.""" +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mkdocs.config.defaults import MkDocsConfig + + from mkapi.plugins import MkAPIPlugin + + +def before_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + """Called before `on_config` event of MkAPI plugin.""" + + +def after_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: + """Called after `on_config` event of MkAPI plugin.""" + + +def page_title(name: str, depth: int) -> str: + """Return a page title.""" + return name + + +def section_title(name: str, depth: int) -> str: + """Return a section title.""" + return name + + +def toc_title(name: str, depth: int) -> str: + """Return a toc title.""" + return name.split(".")[-1] # Remove prefix. Default behavior. +``` + +## Features setting + +MkAPI can be used any MkDocs theme. +However +[Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) +is the best choice because of its navigation features. +Recommended settings are as below: + +
    + +```yaml title="mkdocs.yml" +theme: + name: material (1) + features: + - content.tooltips (2) + - navigation.expand (3) + - navigation.indexes (4) + - navigation.sections (5) + - navigation.tabs (6) +``` + +
    + +1. Use [material theme](https://squidfunk.github.io/mkdocs-material/getting-started/). + +2. MkAPI display object fullnames as tooltips. See + [Improved tooltips](https://squidfunk.github.io/mkdocs-material/reference/tooltips/?h=too#improved-tooltips). + +3. Subpackages or submodules are automatically expanded. + See [Navigation expansion](https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/?h=navigation#navigation-expansion). + +4. Package section can have its own summary or overview page. + See [Section index pages](https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/?h=navigation#section-index-pages). + +5. Packages are rendered as groups in the sidebar. + See [Navigation sections](). + +6. API section can be placed in a menu layer. See + [Navigation tabs](). diff --git a/docs/usage/object.md b/docs/usage/object.md index 50d329e5..ac782842 100644 --- a/docs/usage/object.md +++ b/docs/usage/object.md @@ -1,8 +1,11 @@ # Object mode -## Demonstration package +MkAPI provides Object mode to embed object documentation +in your Markdown source. -In this page, we use a demonstration package: `examples` +## Package for demonstration + +In this page, we use a demonstration package `examples` to describe the Object mode of MkAPI. This package includes one subpackage `styles` and the `styles` subpackage includes two modules: @@ -13,7 +16,10 @@ These two modules are style guides of docstrings: - [Example NumPy Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy) -The directory structure of `examples` package is shown below: +See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/) site +about two styles for more details. + +The directory structure of the `examples` package is shown below: ``` sh examples/ @@ -33,7 +39,7 @@ examples/ ## Top level Package First, let's see the top level package `examples`. -To embed the object documentation in a Markdown text, +To embed the object documentation in a Markdown source, you can write a Markdown syntax like: ```markdown @@ -44,14 +50,15 @@ The three collon (`:::`) must start at the begging of line, followed by a space (` `) and an object fullname, for example, `package.module.function`. Here, just a pakcage name `examples`. -MkAPI finds this syntax in Markdown files and convert it -to the corresponding object documentation: +MkAPI scan Markdown source to find this syntax pattern and +then convert it into the corresponding object documentation +like this: ::: examples !!! note In the above example, the green dashed border - is just guide for the eye to show the region of + is just guide for eyes to clarify the region of the documentation generated by MkAPI. In the above example, a horizontal gray line is an object @@ -60,7 +67,7 @@ A gray text above the line is the fullname of the object. Below the line, the object kind (*package*) and (qualified) name (`examples`) are shown. -This part is a *heading* of documentation. +This part is a *heading* of the object documentation. Below the heading, main contents of documentation are rendered. @@ -74,10 +81,10 @@ documentation. For this, use *filters* like below: ::: examples|source|bare ``` -In this case, two filters `source` and `bare` are used. +Here, two filters `source` and `bare` are used. - `source` ー Embed source code instead of documentation. -- `bare` ー Omit heading part so that only source code are shown. +- `bare` ー Omit the heading part so that only source code is rendered. The output is shown below: @@ -89,13 +96,13 @@ The above docstring is the only content of `examples/__init__.py`. A pakcage can have an `__all__` attribute to provide names that should be imported when `from package import *` is encountered. -(See "[Importing * From a Package][1].") +(See "[Importing * From a Package][1]" of Python documentation.) [1]: https://docs.python.org/3/tutorial/modules.html#importing-from-a-package> -MkAPI recognize the `__all__` attribute and automatically -list up the objects categorized by its kind +MkAPI recognizes the `__all__` attribute and automatically +list up the objects and categorizes them by kind (*module*, *class*, *function*, or *attribute*). In our example, `examples.styles` package have the `__all__` attribute. @@ -116,10 +123,11 @@ Blow is the source code of `examples.styles/__init__.py`. ::: examples.styles|source|bare -Two classes have a diffrent class with the same name of `ExampleClass`. -`examples.styles` uses `import` statement with alias name -(`ExampleClassGoogle` or `ExampleClassNumpy`) to distinct these -two classes. +Two modules (`google` and `numpy`) have their own class +with the same name of `ExampleClass`. +The parent package `examples.styles` uses `import` statement +with alias name (`ExampleClassGoogle` or `ExampleClassNumpy`) +to distinct these two classes. The **Classes** section shows these alias names, but you can check the unaliased fullname by hovering mouse cursor on the names. @@ -137,7 +145,8 @@ the unaliased fullname by hovering mouse cursor on the names. ## Module Python module has classes, functions, or attributes as its members. -A Module documentation can be a docstring of module itself and members list. +A Module documentation can be a docstring of module itself written by the +author and members list automatically generated by MkAPI. ```markdown ::: examples.styles.google @@ -146,29 +155,71 @@ A Module documentation can be a docstring of module itself and members list. ::: examples.styles.google !!! warning - MkAPI doesn't support reStructuredText formatting. + Currently, MkAPI supports a small subset of reStructuredText formatting: + `.. code-block::`, `.. note::`, `.. warning::`, and `.. deprecated::` + directives. The following content must be indented by four spaces from the + start indent of directives. -You can check the correspoing docstring +You can check the corresponding docstring [here][examples.styles.google|source]. !!! note - You can link Markdown text to object documentation or source: + You can link a Markdown text to object (1) documentation or (2) source: - - `[here][examples.styles.google|source]` + 1. `[some text][examples.styles.google]` + 2. `[some text][examples.styles.google|source]` ## Module members +The last part of this page is for module's members. +The syntax to embed these objects is the same as package or module. +But here, a new filter `sourcelink` is introduced. + ### Class +`examples.styles.goole` has a `ExampleClass` class. +You can write like below: + ```markdown -::: examples.styles.google.ExampleClass +::: examples.styles.google.ExampleClass|sourcelink ``` -::: examples.styles.google.ExampleClass +A new `sourcelink` filter is added at the end of the line. +This filter creates a link to the source code +to enable to visit it easily. +Let's see the output. +A `[source]` tag is added in the right side of the heading . +You can click it to see the code. + +A function or attribute can also be embeded in Markdown source +in the same way as described above. + +There are another useful feature. +The heading of object documentation contains the fullname of an object. +This fullname has hierarchical links to parent objects. +In the below examples, the fullname is: + + examples.styles.google.ExampleClass + +Here, + +- The first segment `examples` has a link to the top level pakcage `examples`. +- The second segment `styles` has a link to the subpakcage `examples.styles`. +- The third segment `google` has a link to the module `examples.styles.google`. +- The final segment `ExampleClass` is the corresponding object itself so that a link + has been omitted. + +You can check these links by hovering mouse cursor on the name segments. + +::: examples.styles.google.ExampleClass|sourcelink !!! warning - "\_\_init\_\_" should be written in a inline code (\`\_\_init\_\_\`) - or escaped (\\\_\\\_init\\\_\\\_). + "\_\_init\_\_" should be written in an inline code (\`\_\_init\_\_\`) + or explicitly escaped (\\\_\\\_init\\\_\\\_). + +!!! note + Currently, `__special__` or `_private` members are treated as + a normal member. ### Function @@ -181,7 +232,7 @@ You can check the correspoing docstring ### Attribute ```markdown -::: examples.styles.google.module_level_variable2|sourcelink +::: examples.styles.google.module_level_variable2 ``` -::: examples.styles.google.module_level_variable2|sourcelink +::: examples.styles.google.module_level_variable2 diff --git a/docs/usage/page.md b/docs/usage/page.md index fa719dd4..752f83c0 100644 --- a/docs/usage/page.md +++ b/docs/usage/page.md @@ -1,22 +1,150 @@ # Page mode -```yaml -nav: - - /package - - /package.module -``` +MkAPI provides Page mode to construct a comprehensive +API documentation for your project. + +## Navigation setting + +The use of Page mode is very simple. +Just add a single line to `nav` section in `mkdocs.yml` ```yaml nav: - - Package: /package.* - - Module: /package.module.** + - index.md # normal page. + - /package.module # MkAPI page with a special syntax. ``` -```yaml -nav: - - Package: /package.*** +Here, a __bracket__ (`<...>`) is a marker to indicate that +this page should be processed by MkAPI to generate API +documentation. +The text in the bracket is used as a name of API directory. +In this case, a new API directory `api` is created +in the `docs` directory by MkAPI. +Module page(s) will be located under this directory automatically: + +``` sh +. +├─ docs/ +│ ├─ api/ +│ │ └─ package +│ │ └─ module.md +│ ├─ src/ +│ └─ index.md +└─ mkdocs.yml ``` +!!! note + - You can change the name `api` as long as it is a valid URI or + directory name and it does not exist. + - A `src` directory is also created to locate source codes. + The name `src` is configured by the plugin setting. + See [Configuration](config.md). + +In the above example, just one `pakcge.module` page is created. +In order to obtain a collection of subpackages/submodules, +you can use `*` symbols. +There are three ways: + +=== "1. package.*" + + - Modules under `package` directory are collected. + - `nav` section is extended *vertically*. + + ```yaml + nav: + - index.md + - /package.* + - other.md + ``` + + will be converted into + + ```yaml + nav: + - index.md + - package: api/package/README.md + - module_1: api/package/module_1.md + - module_2: api/package/module_2.md + - other.md + ``` + +=== "2. package.**" + + - Modules under `package` directory and its + subdirectories are collected, recursively. + - `nav` section is extended *vertically* + in flat structure. + + ```yaml + nav: + - index.md + - /package.** + - other.md + ``` + + will be converted into + + ```yaml + nav: + - index.md + - package: api/package/READ.md + - subpackage_1: api/package/subpackage_1/README.md + - module_11: api/package/subpackage_1/module_11.md + - module_21: api/package/subpackage_1/module_12.md + - subpackage_2: api/package/subpackage_2/README.md + - module_21: api/package/subpackage_2/module_21.md + - module_22: api/package/subpackage_2/module_22.md + - module_1: api/package/module_1.md + - module_2: api/package/module_2.md + - other.md + ``` + +=== "3. package.***" + + - Modules under `package` directory and its + subdirectories are collected, recursively. + - `nav` section is extended to have the same tree structure as the package. + - The top section title can be set, for example, `API`. + + ```yaml + nav: + - index.md + - API: /package.** + - other.md + ``` + + will be converted into + + ```yaml + nav: + - index.md + - API: + - package: api/package/READ.md + - subpackage_1: + - subpackage_1: api/package/subpackage_1/README.md + - module_11: api/package/subpackage_1/module_11.md + - module_12: api/package/subpackage_1/module_12.md + - subpackage_2: + - subpackage_2: api/package/subpackage_2/README.md + - module_21: api/package/subpackage_2/module_21.md + - module_22: api/package/subpackage_2/module_22.md + - module_1: api/package/module_1.md + - module_2: api/package/module_2.md + - other.md + ``` + +!!! note + - `README.md` is a index page for packages. Actually it corresponds to `__init__.py` + - Section and page titles can be configured programatically. + See [Configuration](config.md). + - You can set the top setion title as + `
    `: `/package.[***]` like the last case. + +## Example API pages + +To demonstrate the Page mode. This MkAPI documentation ships with +some libraries reference: + - [Schemdraw](https://schemdraw.readthedocs.io/en/stable/) - Schemdraw is a Python package for producing high-quality electrical circuit schematic diagrams. @@ -26,58 +154,36 @@ nav: - [Altair](https://altair-viz.github.io/) - Vega-Altair is a declarative visualization library for Python. -```yaml -nav: - - index.md - - Usage: - - usage/embed.md - - usage/page.md - - Examples: /examples.** - - Schemdraw: /schemdraw.*** - - Polars: /polars.*** - - Altair: /altair.*** -``` +Click section tabs at the top bar or buttons below to see the API documentation. -```yaml -plugins: - - mkapi: - config: config.py - exclude: - - altair.vegalite -``` + -```python -"""Config functions.""" -from __future__ import annotations +
    +[Schemdraw][schemdraw]{.md-button .md-button--primary} +[Polars][polars]{.md-button .md-button--primary} +[Altair][altair]{.md-button .md-button--primary} +
    -import sys -from typing import TYPE_CHECKING +__Note that MkAPI processed the docstrings of +these libraries without any modification.__ -if TYPE_CHECKING: - from mkdocs.config.defaults import MkDocsConfig +Here is the actual `nav` section in `mkdocs.yml` of this documentation. +Use this to reproduce the similar navigation structure for your project if you like. - from mkapi.plugins import MkAPIPlugin - - -def before_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: - """Called before `on_config` event of MkAPI plugin.""" - - -def after_on_config(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: - """Called after `on_config` event of MkAPI plugin.""" - - -def page_title(name: str, depth: int) -> str: - """Return a page title.""" - return name - - -def section_title(name: str, depth: int) -> str: - """Return a section title.""" - return name - - -def toc_title(name: str, depth: int) -> str: - """Return a toc title.""" - return name.split(".")[-1] # Remove prefix. +```yaml +nav: + - index.md + - Usage: # Actual MkAPI documentation + - usage/object.md + - usage/page.md + - usage/config.md + - Examples: /examples.** # for Object mode description + - Schemdraw: /schemdraw.*** # for Page mode demonstration + - Polars: /polars.*** # for Page mode demonstration + - Altair: /altair.*** # for Page mode demonstration ``` diff --git a/mkdocs.yml b/mkdocs.yml index cd9b9284..25ed9486 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,8 +27,8 @@ theme: name: Switch to light mode features: - content.tooltips + - content.code.annotate - navigation.expand - - navigation.footer - navigation.indexes - navigation.instant - navigation.sections @@ -42,20 +42,23 @@ plugins: exclude: - altair.vegalite debug: true - # filters: [plugin_filter] markdown_extensions: - pymdownx.magiclink - pymdownx.highlight: use_pygments: true - pymdownx.inlinehilite - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true nav: - index.md - Usage: - usage/object.md - usage/page.md - # - API: /mkapi.*** + - usage/config.md - Examples: /examples.** - # - Schemdraw: /schemdraw.*** - # - Polars: /polars.*** - # - Altair: /altair.*** \ No newline at end of file + - Schemdraw: /schemdraw.*** + - Polars: /polars.*** + - Altair: /altair.*** +extra_css: + - stylesheets/extra.css \ No newline at end of file diff --git a/src/mkapi/plugins.py b/src/mkapi/plugins.py index afc087a6..3402a7eb 100644 --- a/src/mkapi/plugins.py +++ b/src/mkapi/plugins.py @@ -225,7 +225,7 @@ def _update_nav(config: MkDocsConfig, plugin: MkAPIPlugin) -> None: section_title = _get_function("section_title", plugin) def _create_page(name: str, path: str, filters: list[str], depth: int) -> str: - spinner.text = f"Updating nav...: {name}" + spinner.text = f"Updating nav...: [{2*len(MkAPIPlugin.api_uris):>3}] {name}" MkAPIPlugin.api_uris.append(path) abs_path = Path(config.docs_dir) / path _check_path(abs_path) @@ -240,6 +240,8 @@ def _create_page(name: str, path: str, filters: list[str], depth: int) -> str: return page_title(name, depth) if page_title else name def predicate(name: str) -> bool: + if not plugin.config.exclude: + return True return any(ex not in name for ex in plugin.config.exclude) with warnings.catch_warnings(): diff --git a/src/mkapi/themes/mkapi-material.css b/src/mkapi/themes/mkapi-material.css index 12b9c65a..e5d80fd2 100644 --- a/src/mkapi/themes/mkapi-material.css +++ b/src/mkapi/themes/mkapi-material.css @@ -96,15 +96,13 @@ li.mkapi-section-item li { } .mkapi-example-input code { font-size: 85%; - background-color: var(--md-default-fg-color--lightest); } .mkapi-example-output code { font-size: 85%; - font-family: monospace; - background-color: var(--md-code-bg-color); + border: solid var(--md-code-bg-color); + background-color: transparent; } .mkapi-source code { - font-family: monospace; font-size: 85%; } .mkapi-docs-link { diff --git a/tests/test_objects.py b/tests/test_objects.py index 36e75eb0..5f202f41 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -297,3 +297,8 @@ def test_set_markdown(): assert isinstance(obj, Function) m = obj.doc.text.markdown assert m == "Yield [Raise][__mkapi__.mkapi.items.Raise] instances." + + +# schemdraw.elements.intcircuits.Ic +# IcDIP +# Keyword argsの違い From 29d70041d0a09ca897b9b323bfef8244d5ba383f Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 7 Feb 2024 20:07:06 +0900 Subject: [PATCH 147/148] Update CI --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d38d5095..2f34422d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,19 +25,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Linux dependencies - if: startsWith(runner.os, 'Linux') - run: | - sudo apt-get update - sudo apt-get install texlive-plain-generic inkscape texlive-xetex latexmk - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Hatch run: pip install --upgrade hatch - - name: Run static analysis - run: hatch fmt --check - name: Run tests run: hatch run test - name: Upload Codecov Results From e6bfdc574c890eaadf65a2d6926b21a7faa6562e Mon Sep 17 00:00:00 2001 From: daizutabi Date: Wed, 7 Feb 2024 20:17:27 +0900 Subject: [PATCH 148/148] find_submodule_names --- src/mkapi/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mkapi/utils.py b/src/mkapi/utils.py index 463ab8bf..3d277153 100644 --- a/src/mkapi/utils.py +++ b/src/mkapi/utils.py @@ -67,7 +67,7 @@ def find_submodule_names( """ predicate = predicate or (lambda _: True) names = [name for name in iter_submodule_names(name) if predicate(name)] - names.sort(key=lambda x: not is_package(x)) + names.sort(key=lambda x: [not is_package(x), x]) return names