diff options
author | Timo Röhling <roehling@debian.org> | 2023-11-19 00:55:40 +0100 |
---|---|---|
committer | Timo Röhling <roehling@debian.org> | 2023-11-19 00:55:40 +0100 |
commit | b20a4d73e8176f0bfcd40ed36842d4d3aeb51748 (patch) | |
tree | c0b0b1f5ea8c1be6526536971555bd48c935022c |
New upstream version 3.27.0
-rw-r--r-- | .github/dependabot.yml | 7 | ||||
-rw-r--r-- | .github/workflows/cd.yml | 43 | ||||
-rw-r--r-- | .github/workflows/ci.yml | 31 | ||||
-rw-r--r-- | .gitignore | 163 | ||||
-rw-r--r-- | .pre-commit-config.yaml | 20 | ||||
-rw-r--r-- | LICENSE | 34 | ||||
-rw-r--r-- | README.md | 116 | ||||
-rw-r--r-- | noxfile.py | 65 | ||||
-rw-r--r-- | pyproject.toml | 60 | ||||
-rw-r--r-- | sphinxcontrib/moderncmakedomain/__init__.py | 3 | ||||
-rw-r--r-- | sphinxcontrib/moderncmakedomain/cmake.py | 748 | ||||
-rw-r--r-- | sphinxcontrib/moderncmakedomain/colors.py | 29 | ||||
-rw-r--r-- | tests/conftest.py | 9 | ||||
-rw-r--r-- | tests/roots/test-root/conf.py | 1 | ||||
-rw-r--r-- | tests/roots/test-root/external.rst | 5 | ||||
-rw-r--r-- | tests/roots/test-root/index.rst | 7 | ||||
-rw-r--r-- | tests/roots/test-root/local.rst | 17 | ||||
-rw-r--r-- | tests/roots/test-root/more.rst | 5 | ||||
-rw-r--r-- | tests/roots/test-root/padding.rst | 4 | ||||
-rw-r--r-- | tests/roots/test-root/parallel.rst | 16 | ||||
-rw-r--r-- | tests/test_basic.py | 27 | ||||
-rw-r--r-- | tests/test_version.py | 7 |
22 files changed, 1417 insertions, 0 deletions
diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2c7d170 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..1dabd41 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,43 @@ +name: CD + +on: + workflow_dispatch: + release: + types: + - published + + +jobs: + dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build SDist and wheel + run: pipx run build + + - uses: actions/upload-artifact@v3 + with: + path: dist/* + + - name: Check metadata + run: pipx run twine check dist/* + + + publish: + needs: [dist] + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + environment: + name: pypi + url: https://pypi.org/p/sphinxcontrib-moderncmakedomain + permissions: + id-token: write + + steps: + - uses: actions/download-artifact@v3 + with: + name: artifact + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0aeaf9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +env: + FORCE_COLOR: 3 + +jobs: + check-package: + name: Build & inspect + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: hynek/build-and-inspect-python-package@v1 + + test: + name: Run quick tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: wntrblm/nox@2023.04.22 + with: + python-versions: "3.7, 3.8, 3.9, 3.10, 3.11, 3.12-dev" + - run: nox -s tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c513a42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# 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/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# OS-specific +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a10bd4e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +ci: + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.3.0" + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: name-tests-test + args: ["--pytest-test-first"] + - id: requirements-txt-fixer + - id: trailing-whitespace @@ -0,0 +1,34 @@ +CMake - Cross Platform Makefile Generator +Copyright 2000-2018 Kitware, Inc. and Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of Kitware, Inc. nor the names of Contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +See https://cmake.org/licensing for more details diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4b4193 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Sphinx Domain for Modern CMake + +This is taken directly from the Kitware git repository's Utilities directory. +The original [sphinxcontrib-cmakedomain][] has not been touched in quite some and +as a result it was wildly out of date. Documenting CMake domain entities in +projects is painful otherwise. This works *exactly* in the same way as Kitware, +so some time might be needed to study their approach to these problems. + +This repository is under the same License as all of CMake, which is the +BSD-3-Clause license. + +🚨🚨🚨 +Any issues you run into with this plugin must be reported to [Kitware][], +unless they involve the packaging itself. The Python files exactly match +the CMake source for the released version numbers. +🚨🚨🚨 + +# Installation + +## PyPI + +This domain is available via PyPI. Install it directly via `pip`: + +``` +$ pip install sphinxcontrib-moderncmakedomain +``` + +Alternatively, place it inside of your `setup.py`, `pyproject.toml`, +`requirements.txt` or whatever system it is that you use to declare and manage +your dependencies. A new version will usually only be released if there is a +change to this extension inside CMake. + +## Git + +This module is installable via `pip` and GitHub directly as well + +``` +$ pip install git+https://github.com/scikit-build/moderncmakedomain.git +``` + +# Usage + +To enable the use of the `moderncmakedomain`, add +`sphinxcontrib.moderncmakedomain` to the `extensions` variable of your +`conf.py` file: + +```python +extensions = [..., 'sphinxcontrib.moderncmakedomain', ...] +``` + +The plugin currently provides several directives and references. These are +documented below. + +## Directives + +| directive | description | +|:------------------:|:----------------------------------------------------| +| `cmake:variable::` | For a basic variable | +| `cmake:command::` | For a function | +| `cmake-module::` | Autodoc style extractor (takes a relative filepath) | +| `cmake:envvar::` | For environment variables | + +To declare any of the references found below, they must be placed into a +directory with the same name under the sphinx SOURCEDIR/master doc. Thus, +`prop_tgt/MY_PERSONAL_PROPERTY.rst` can be referred to with +``:prop_tgt:`MY_PERSONAL_PROPERTY` ``. This is currently the *only* way CMake +permits declaring new properties. + +## References + +Each reference below can be placed into a directory with the same name to +document custom extensions provided by your CMake libraries. + +| ref | description | +|:--------------:|:---------------------------------------------------| +| `:variable:` | Refer to a CMake variable | +| `:command:` | Refer to a CMake command | +| `:envvar:` | Refers to an environment variable | +| `:cpack_gen:` | Refers to CPack generators | +| `:generator:` | Refers to a build file generator | +| `:genex:` | Refers to a generator expression | +| `:guide:` | Used to refer to a "guide" page | +| `:manual:` | Used to refer to a "manual" page (like `cmake(1)`) | +| `:policy:` | Refers to CMake Policies | +| `:module:` | Refers to CMake Modules | +| `:prop_tgt:` | For target properties | +| `:prop_test:` | For test properties | +| `:prop_sf:` | For source file properties | +| `:prop_gbl:` | For global properties | +| `:prop_dir:` | For directory properties | +| `:prop_inst:` | For installed file properties | +| `:prop_cache:` | For cache properties | + +# History + +`sphinx-moderncmakedomain` was initially developed in October 2018 by +[slurps-mad-rips][slurps-mad-rips] to help write CMake documentation by simply +publishing a python package of the same. This was a critical step to ease the +maintenance of sphinx-based documentation and avoid systematically copying the +associated python module maintained within the CMake repository. + +Later in early August 2021, [henryiii][henryiii] discovered the +`sphinx-moderncmakedomain` project while working on scikit-build issue +[#574][skbuild-issue-574] intended to simplify its documentation generation +infrastructure and avoid updating its own copy of the sphinx extension. +[henryiii][henryiii] and [jcfr][jcfr] then worked with +[slurps-mad-rips][slurps-mad-rips] to establish a transition plan to +collaboratively maintain the project within the scikit-build organization. + +[sphinxcontrib-cmakedomain]: https://github.com/sphinx-contrib/cmakedomain +[Kitware]: https://gitlab.kitware.com/ + +[skbuild-issue-574]: https://github.com/scikit-build/scikit-build/pull/574 +[slurps-mad-rips]: https://github.com/slurps-mad-rips +[henryiii]: https://github.com/henryiii +[jcfr]: https://github.com/jcfr diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..d1337f4 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,65 @@ +import nox +import urllib.request +import re +from pathlib import Path + +nox.options.sessions = ["lint", "tests"] + +ALL_PYTHONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + +@nox.session +def lint(session: nox.Session) -> None: + """ + Run the linter. + """ + session.install("pre-commit") + session.run( + "pre-commit", "run", "--all-files", "--hook-stage=manual", *session.posargs + ) + + +@nox.session +def build(session: nox.Session) -> None: + """ + Build an SDist and wheel. + """ + + session.install("build") + session.run("python", "-m", "build") + + +@nox.session +def update(session: nox.Session) -> None: + """ + Get the latest (or given) version of CMake and update the copy with it. + """ + + if session.posargs: + (version,) = session.posargs + else: + session.install("lastversion") + version = session.run( + "lastversion", "kitware/cmake", log=False, silent=True + ).strip() + session.log(f"CMake {version}") + + cmake_url = f"https://raw.githubusercontent.com/Kitware/CMake/v{version}/Utilities/Sphinx/cmake.py" + colors_url = f"https://raw.githubusercontent.com/Kitware/CMake/v{version}/Utilities/Sphinx/colors.py" + + urllib.request.urlretrieve(cmake_url, "sphinxcontrib/moderncmakedomain/cmake.py") + urllib.request.urlretrieve(colors_url, "sphinxcontrib/moderncmakedomain/colors.py") + + init_file = Path("sphinxcontrib/moderncmakedomain/__init__.py") + txt = init_file.read_text(encoding="utf_8") + txt_new = re.sub(r'__version__ = ".*"', f'__version__ = "{version}"', txt) + init_file.write_text(txt_new, encoding="utf_8") + + +@nox.session(python=ALL_PYTHONS) +def tests(session): + """ + Run the unit and regular tests. + """ + # Setuptools is required due to sphinx installing sphinxcontrib extensions that use pkg_resources (fixed upstream but not released yet) + session.install(".", "pytest", "setuptools") + session.run("pytest", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6fbdac9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinxcontrib-moderncmakedomain" +description = "Sphinx Domain for Modern CMake" +readme = "README.md" +requires-python = ">=3.7" +authors = [ + { name = "Kitware" }, +] +keywords = [ + "cmake", + "documentation", + "kitware", + "sphinx", + "sphinxcontrib", +] +classifiers = [ + "Environment :: Console", + "Environment :: Web Environment", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Documentation", + "Topic :: Utilities", +] +dependencies = ["sphinx>=2"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/scikit-build/moderncmakedomain" + +[project.optional-dependencies] +test = ["pytest"] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] +xfail_strict = true +filterwarnings = [ + "error", + "ignore::DeprecationWarning:sphinx.builders.gettext", +] +log_cli_level = "info" +testpaths = ["tests"] + +[tool.hatch] +version.path = "sphinxcontrib/moderncmakedomain/__init__.py" +build.targets.wheel.packages = ["sphinxcontrib"] diff --git a/sphinxcontrib/moderncmakedomain/__init__.py b/sphinxcontrib/moderncmakedomain/__init__.py new file mode 100644 index 0000000..9a77fde --- /dev/null +++ b/sphinxcontrib/moderncmakedomain/__init__.py @@ -0,0 +1,3 @@ +from .cmake import setup + +__version__ = "3.27.0" diff --git a/sphinxcontrib/moderncmakedomain/cmake.py b/sphinxcontrib/moderncmakedomain/cmake.py new file mode 100644 index 0000000..66954df --- /dev/null +++ b/sphinxcontrib/moderncmakedomain/cmake.py @@ -0,0 +1,748 @@ +# Distributed under the OSI-approved BSD 3-Clause License. See accompanying +# file Copyright.txt or https://cmake.org/licensing for details. + +# BEGIN imports + +import os +import re +from dataclasses import dataclass +from typing import Any, List, Tuple, Type, cast + +import sphinx + +# The following imports may fail if we don't have Sphinx 2.x or later. +if sphinx.version_info >= (2,): + from docutils import io, nodes + from docutils.nodes import Element, Node, TextElement, system_message + from docutils.parsers.rst import Directive, directives + from docutils.transforms import Transform + from docutils.utils.code_analyzer import Lexer, LexerError + + from sphinx import addnodes + from sphinx.directives import ObjectDescription, nl_escape_re + from sphinx.domains import Domain, ObjType + from sphinx.roles import XRefRole + from sphinx.util import logging, ws_re + from sphinx.util.docutils import ReferenceRole + from sphinx.util.nodes import make_refnode +else: + # Sphinx 2.x is required. + assert sphinx.version_info >= (2,) + +# END imports + +# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +# BEGIN pygments tweaks + +# Override much of pygments' CMakeLexer. +# We need to parse CMake syntax definitions, not CMake code. + +# For hard test cases that use much of the syntax below, see +# - module/FindPkgConfig.html +# (with "glib-2.0>=2.10 gtk+-2.0" and similar) +# - module/ExternalProject.html +# (with http:// https:// git@; also has command options -E --build) +# - manual/cmake-buildsystem.7.html +# (with nested $<..>; relative and absolute paths, "::") + +from pygments.lexer import bygroups # noqa I100 +from pygments.lexers import CMakeLexer +from pygments.token import (Comment, Name, Number, Operator, Punctuation, + String, Text, Whitespace) + +# Notes on regular expressions below: +# - [\.\+-] are needed for string constants like gtk+-2.0 +# - Unix paths are recognized by '/'; support for Windows paths may be added +# if needed +# - (\\.) allows for \-escapes (used in manual/cmake-language.7) +# - $<..$<..$>..> nested occurrence in cmake-buildsystem +# - Nested variable evaluations are only supported in a limited capacity. +# Only one level of nesting is supported and at most one nested variable can +# be present. + +CMakeLexer.tokens["root"] = [ + # fctn( + (r'\b(\w+)([ \t]*)(\()', + bygroups(Name.Function, Text, Name.Function), '#push'), + (r'\(', Name.Function, '#push'), + (r'\)', Name.Function, '#pop'), + (r'\[', Punctuation, '#push'), + (r'\]', Punctuation, '#pop'), + (r'[|;,.=*\-]', Punctuation), + # used in commands/source_group + (r'\\\\', Punctuation), + (r'[:]', Operator), + # used in FindPkgConfig.cmake + (r'[<>]=', Punctuation), + # $<...> + (r'\$<', Operator, '#push'), + # <expr> + (r'<[^<|]+?>(\w*\.\.\.)?', Name.Variable), + # ${..} $ENV{..}, possibly nested + (r'(\$\w*\{)([^\}\$]*)?(?:(\$\w*\{)([^\}]+?)(\}))?([^\}]*?)(\})', + bygroups(Operator, Name.Tag, Operator, Name.Tag, Operator, Name.Tag, + Operator)), + # DATA{ ...} + (r'([A-Z]+\{)(.+?)(\})', bygroups(Operator, Name.Tag, Operator)), + # URL, git@, ... + (r'[a-z]+(@|(://))((\\.)|[\w.+-:/\\])+', Name.Attribute), + # absolute path + (r'/\w[\w\.\+-/\\]*', Name.Attribute), + (r'/', Name.Attribute), + # relative path + (r'\w[\w\.\+-]*/[\w.+-/\\]*', Name.Attribute), + # initial A-Z, contains a-z + (r'[A-Z]((\\.)|[\w.+-])*[a-z]((\\.)|[\w.+-])*', Name.Builtin), + (r'@?[A-Z][A-Z0-9_]*', Name.Constant), + (r'[a-z_]((\\;)|(\\ )|[\w.+-])*', Name.Builtin), + (r'[0-9][0-9\.]*', Number), + # "string" + (r'(?s)"(\\"|[^"])*"', String), + (r'\.\.\.', Name.Variable), + # <..|..> is different from <expr> + (r'<', Operator, '#push'), + (r'>', Operator, '#pop'), + (r'\n', Whitespace), + (r'[ \t]+', Whitespace), + (r'#.*\n', Comment), + # fallback, for debugging only + # (r'[^<>\])\}\|$"# \t\n]+', Name.Exception), +] + +# END pygments tweaks + +# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +logger = logging.getLogger(__name__) + +# RE to split multiple command signatures. +sig_end_re = re.compile(r'(?<=[)])\n') + + +@dataclass +class ObjectEntry: + docname: str + objtype: str + node_id: str + name: str + + +class CMakeModule(Directive): + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + option_spec = {'encoding': directives.encoding} + + def __init__(self, *args, **keys): + self.re_start = re.compile(r'^#\[(?P<eq>=*)\[\.rst:$') + Directive.__init__(self, *args, **keys) + + def run(self): + settings = self.state.document.settings + if not settings.file_insertion_enabled: + raise self.warning(f'{self.name!r} directive disabled.') + + env = self.state.document.settings.env + rel_path, path = env.relfn2path(self.arguments[0]) + path = os.path.normpath(path) + encoding = self.options.get('encoding', settings.input_encoding) + e_handler = settings.input_encoding_error_handler + try: + settings.record_dependencies.add(path) + f = io.FileInput(source_path=path, encoding=encoding, + error_handler=e_handler) + except UnicodeEncodeError: + msg = (f'Problems with {self.name!r} directive path:\n' + f'Cannot encode input file path {path!r} (wrong locale?).') + raise self.severe(msg) + except IOError as error: + msg = f'Problems with {self.name!r} directive path:\n{error}.' + raise self.severe(msg) + raw_lines = f.read().splitlines() + f.close() + rst = None + lines = [] + for line in raw_lines: + if rst is not None and rst != '#': + # Bracket mode: check for end bracket + pos = line.find(rst) + if pos >= 0: + if line[0] == '#': + line = '' + else: + line = line[0:pos] + rst = None + else: + # Line mode: check for .rst start (bracket or line) + m = self.re_start.match(line) + if m: + rst = f']{m.group("eq")}]' + line = '' + elif line == '#.rst:': + rst = '#' + line = '' + elif rst == '#': + if line == '#' or line[:2] == '# ': + line = line[2:] + else: + rst = None + line = '' + elif rst is None: + line = '' + lines.append(line) + if rst is not None and rst != '#': + raise self.warning(f'{self.name!r} found unclosed bracket ' + f'"#[{rst[1:-1]}[.rst:" in {path!r}') + self.state_machine.insert_input(lines, path) + return [] + + +class _cmake_index_entry: + def __init__(self, desc): + self.desc = desc + + def __call__(self, title, targetid, main='main'): + return ('pair', f'{self.desc} ; {title}', targetid, main, None) + + +_cmake_index_objs = { + 'command': _cmake_index_entry('command'), + 'cpack_gen': _cmake_index_entry('cpack generator'), + 'envvar': _cmake_index_entry('envvar'), + 'generator': _cmake_index_entry('generator'), + 'genex': _cmake_index_entry('genex'), + 'guide': _cmake_index_entry('guide'), + 'manual': _cmake_index_entry('manual'), + 'module': _cmake_index_entry('module'), + 'policy': _cmake_index_entry('policy'), + 'prop_cache': _cmake_index_entry('cache property'), + 'prop_dir': _cmake_index_entry('directory property'), + 'prop_gbl': _cmake_index_entry('global property'), + 'prop_inst': _cmake_index_entry('installed file property'), + 'prop_sf': _cmake_index_entry('source file property'), + 'prop_test': _cmake_index_entry('test property'), + 'prop_tgt': _cmake_index_entry('target property'), + 'variable': _cmake_index_entry('variable'), + } + + +class CMakeTransform(Transform): + + # Run this transform early since we insert nodes we want + # treated as if they were written in the documents. + default_priority = 210 + + def __init__(self, document, startnode): + Transform.__init__(self, document, startnode) + self.titles = {} + + def parse_title(self, docname): + """Parse a document title as the first line starting in [A-Za-z0-9<$] + or fall back to the document basename if no such line exists. + The cmake --help-*-list commands also depend on this convention. + Return the title or False if the document file does not exist. + """ + settings = self.document.settings + env = settings.env + title = self.titles.get(docname) + if title is None: + fname = os.path.join(env.srcdir, docname+'.rst') + try: + f = open(fname, 'r', encoding=settings.input_encoding) + except IOError: + title = False + else: + for line in f: + if len(line) > 0 and (line[0].isalnum() or + line[0] == '<' or line[0] == '$'): + title = line.rstrip() + break + f.close() + if title is None: + title = os.path.basename(docname) + self.titles[docname] = title + return title + + def apply(self): + env = self.document.settings.env + + # Treat some documents as cmake domain objects. + objtype, sep, tail = env.docname.partition('/') + make_index_entry = _cmake_index_objs.get(objtype) + if make_index_entry: + title = self.parse_title(env.docname) + # Insert the object link target. + if objtype == 'command': + targetname = title.lower() + elif objtype == 'guide' and not tail.endswith('/index'): + targetname = tail + else: + if objtype == 'genex': + m = CMakeXRefRole._re_genex.match(title) + if m: + title = m.group(1) + targetname = title + targetid = f'{objtype}:{targetname}' + targetnode = nodes.target('', '', ids=[targetid]) + self.document.note_explicit_target(targetnode) + self.document.insert(0, targetnode) + # Insert the object index entry. + indexnode = addnodes.index() + indexnode['entries'] = [make_index_entry(title, targetid)] + self.document.insert(0, indexnode) + + # Add to cmake domain object inventory + domain = cast(CMakeDomain, env.get_domain('cmake')) + domain.note_object(objtype, targetname, targetid, targetid) + + +class CMakeObject(ObjectDescription): + def __init__(self, *args, **kwargs): + self.targetname = None + super().__init__(*args, **kwargs) + + def handle_signature(self, sig, signode): + # called from sphinx.directives.ObjectDescription.run() + signode += addnodes.desc_name(sig, sig) + return sig + + def add_target_and_index(self, name, sig, signode): + if self.objtype == 'command': + targetname = name.lower() + elif self.targetname: + targetname = self.targetname + else: + targetname = name + targetid = f'{self.objtype}:{targetname}' + if targetid not in self.state.document.ids: + signode['names'].append(targetid) + signode['ids'].append(targetid) + signode['first'] = (not self.names) + self.state.document.note_explicit_target(signode) + + domain = cast(CMakeDomain, self.env.get_domain('cmake')) + domain.note_object(self.objtype, targetname, targetid, targetid, + location=signode) + + make_index_entry = _cmake_index_objs.get(self.objtype) + if make_index_entry: + self.indexnode['entries'].append(make_index_entry(name, targetid)) + + +class CMakeGenexObject(CMakeObject): + option_spec = { + 'target': directives.unchanged, + } + + def handle_signature(self, sig, signode): + name = super().handle_signature(sig, signode) + + m = CMakeXRefRole._re_genex.match(sig) + if m: + name = m.group(1) + + return name + + def run(self): + target = self.options.get('target') + if target is not None: + self.targetname = target + + return super().run() + + +class CMakeSignatureObject(CMakeObject): + object_type = 'signature' + + BREAK_ALL = 'all' + BREAK_SMART = 'smart' + BREAK_VERBATIM = 'verbatim' + + BREAK_CHOICES = {BREAK_ALL, BREAK_SMART, BREAK_VERBATIM} + + def break_option(argument): + return directives.choice(argument, CMakeSignatureObject.BREAK_CHOICES) + + option_spec = { + 'target': directives.unchanged, + 'break': break_option, + } + + def _break_signature_all(sig: str) -> str: + return ws_re.sub(' ', sig) + + def _break_signature_verbatim(sig: str) -> str: + lines = [ws_re.sub('\xa0', line.strip()) for line in sig.split('\n')] + return ' '.join(lines) + + def _break_signature_smart(sig: str) -> str: + tokens = [] + for line in sig.split('\n'): + token = '' + delim = '' + + for c in line.strip(): + if len(delim) == 0 and ws_re.match(c): + if len(token): + tokens.append(ws_re.sub('\xa0', token)) + token = '' + else: + if c == '[': + delim += ']' + elif c == '<': + delim += '>' + elif len(delim) and c == delim[-1]: + delim = delim[:-1] + token += c + + if len(token): + tokens.append(ws_re.sub('\xa0', token)) + + return ' '.join(tokens) + + def __init__(self, *args, **kwargs): + self.targetnames = {} + self.break_style = CMakeSignatureObject.BREAK_SMART + super().__init__(*args, **kwargs) + + def get_signatures(self) -> List[str]: + content = nl_escape_re.sub('', self.arguments[0]) + lines = sig_end_re.split(content) + + if self.break_style == CMakeSignatureObject.BREAK_VERBATIM: + fixup = CMakeSignatureObject._break_signature_verbatim + elif self.break_style == CMakeSignatureObject.BREAK_SMART: + fixup = CMakeSignatureObject._break_signature_smart + else: + fixup = CMakeSignatureObject._break_signature_all + + return [fixup(line.strip()) for line in lines] + + def handle_signature(self, sig, signode): + language = 'cmake' + classes = ['code', 'cmake', 'highlight'] + + node = addnodes.desc_name(sig, '', classes=classes) + + try: + tokens = Lexer(sig, language, 'short') + except LexerError as error: + if self.state.document.settings.report_level > 2: + # Silently insert without syntax highlighting. + tokens = Lexer(sig, language, 'none') + else: + raise self.warning(error) + + for classes, value in tokens: + if value == '\xa0': + node += nodes.inline(value, value, classes=['nbsp']) + elif classes: + node += nodes.inline(value, value, classes=classes) + else: + node += nodes.Text(value) + + signode.clear() + signode += node + + return sig + + def add_target_and_index(self, name, sig, signode): + sig = sig.replace('\xa0', ' ') + if sig in self.targetnames: + sigargs = self.targetnames[sig] + else: + def extract_keywords(params): + for p in params: + if p[0].isalpha(): + yield p + else: + return + + keywords = extract_keywords(sig.split('(')[1].split()) + sigargs = ' '.join(keywords) + targetname = sigargs.lower() + targetid = nodes.make_id(targetname) + + if targetid not in self.state.document.ids: + signode['names'].append(targetname) + signode['ids'].append(targetid) + signode['first'] = (not self.names) + self.state.document.note_explicit_target(signode) + + # Register the signature as a command object. + command = sig.split('(')[0].lower() + refname = f'{command}({sigargs})' + refid = f'command:{command}({targetname})' + + domain = cast(CMakeDomain, self.env.get_domain('cmake')) + domain.note_object('command', name=refname, target_id=refid, + node_id=targetid, location=signode) + + def run(self): + self.break_style = CMakeSignatureObject.BREAK_ALL + + targets = self.options.get('target') + if targets is not None: + signatures = self.get_signatures() + targets = [t.strip() for t in targets.split('\n')] + for signature, target in zip(signatures, targets): + self.targetnames[signature] = target + + self.break_style = ( + self.options.get('break', CMakeSignatureObject.BREAK_SMART)) + + return super().run() + + +class CMakeReferenceRole: + # See sphinx.util.nodes.explicit_title_re; \x00 escapes '<'. + _re = re.compile(r'^(.+?)(\s*)(?<!\x00)<(.*?)>$', re.DOTALL) + + @staticmethod + def _escape_angle_brackets(text: str) -> str: + # CMake cross-reference targets frequently contain '<' so escape + # any explicit `<target>` with '<' not preceded by whitespace. + while True: + m = CMakeReferenceRole._re.match(text) + if m and len(m.group(2)) == 0: + text = f'{m.group(1)}\x00<{m.group(3)}>' + else: + break + return text + + def __class_getitem__(cls, parent: Any): + class Class(parent): + def __call__(self, name: str, rawtext: str, text: str, + *args, **kwargs + ) -> Tuple[List[Node], List[system_message]]: + text = CMakeReferenceRole._escape_angle_brackets(text) + return super().__call__(name, rawtext, text, *args, **kwargs) + return Class + + +class CMakeCRefRole(CMakeReferenceRole[ReferenceRole]): + nodeclass: Type[Element] = nodes.reference + innernodeclass: Type[TextElement] = nodes.literal + classes: List[str] = ['cmake', 'literal'] + + def run(self) -> Tuple[List[Node], List[system_message]]: + refnode = self.nodeclass(self.rawtext) + self.set_source_info(refnode) + + refnode['refid'] = nodes.make_id(self.target) + refnode += self.innernodeclass(self.rawtext, self.title, + classes=self.classes) + + return [refnode], [] + + +class CMakeXRefRole(CMakeReferenceRole[XRefRole]): + + _re_sub = re.compile(r'^([^()\s]+)\s*\(([^()]*)\)$', re.DOTALL) + _re_genex = re.compile(r'^\$<([^<>:]+)(:[^<>]+)?>$', re.DOTALL) + _re_guide = re.compile(r'^([^<>/]+)/([^<>]*)$', re.DOTALL) + + def __call__(self, typ, rawtext, text, *args, **kwargs): + if typ == 'cmake:command': + # Translate a CMake command cross-reference of the form: + # `command_name(SUB_COMMAND)` + # to be its own explicit target: + # `command_name(SUB_COMMAND) <command_name(SUB_COMMAND)>` + # so the XRefRole `fix_parens` option does not add more `()`. + m = CMakeXRefRole._re_sub.match(text) + if m: + text = f'{text} <{text}>' + elif typ == 'cmake:genex': + m = CMakeXRefRole._re_genex.match(text) + if m: + text = f'{text} <{m.group(1)}>' + elif typ == 'cmake:guide': + m = CMakeXRefRole._re_guide.match(text) + if m: + text = f'{m.group(2)} <{text}>' + return super().__call__(typ, rawtext, text, *args, **kwargs) + + # We cannot insert index nodes using the result_nodes method + # because CMakeXRefRole is processed before substitution_reference + # nodes are evaluated so target nodes (with 'ids' fields) would be + # duplicated in each evaluated substitution replacement. The + # docutils substitution transform does not allow this. Instead we + # use our own CMakeXRefTransform below to add index entries after + # substitutions are completed. + # + # def result_nodes(self, document, env, node, is_ref): + # pass + + +class CMakeXRefTransform(Transform): + + # Run this transform early since we insert nodes we want + # treated as if they were written in the documents, but + # after the sphinx (210) and docutils (220) substitutions. + default_priority = 221 + + # This helper supports docutils < 0.18, which is missing 'findall', + # and docutils == 0.18.0, which is missing 'traverse'. + def _document_findall_as_list(self, condition): + if hasattr(self.document, 'findall'): + # Fully iterate into a list so the caller can grow 'self.document' + # while iterating. + return list(self.document.findall(condition)) + + # Fallback to 'traverse' on old docutils, which returns a list. + return self.document.traverse(condition) + + def apply(self): + env = self.document.settings.env + + # Find CMake cross-reference nodes and add index and target + # nodes for them. + for ref in self._document_findall_as_list(addnodes.pending_xref): + if not ref['refdomain'] == 'cmake': + continue + + objtype = ref['reftype'] + make_index_entry = _cmake_index_objs.get(objtype) + if not make_index_entry: + continue + + objname = ref['reftarget'] + if objtype == 'guide' and CMakeXRefRole._re_guide.match(objname): + # Do not index cross-references to guide sections. + continue + + if objtype == 'command': + # Index signature references to their parent command. + objname = objname.split('(')[0].lower() + + targetnum = env.new_serialno(f'index-{objtype}:{objname}') + + targetid = f'index-{targetnum}-{objtype}:{objname}' + targetnode = nodes.target('', '', ids=[targetid]) + self.document.note_explicit_target(targetnode) + + indexnode = addnodes.index() + indexnode['entries'] = [make_index_entry(objname, targetid, '')] + ref.replace_self([indexnode, targetnode, ref]) + + +class CMakeDomain(Domain): + """CMake domain.""" + name = 'cmake' + label = 'CMake' + object_types = { + 'command': ObjType('command', 'command'), + 'cpack_gen': ObjType('cpack_gen', 'cpack_gen'), + 'envvar': ObjType('envvar', 'envvar'), + 'generator': ObjType('generator', 'generator'), + 'genex': ObjType('genex', 'genex'), + 'guide': ObjType('guide', 'guide'), + 'variable': ObjType('variable', 'variable'), + 'module': ObjType('module', 'module'), + 'policy': ObjType('policy', 'policy'), + 'prop_cache': ObjType('prop_cache', 'prop_cache'), + 'prop_dir': ObjType('prop_dir', 'prop_dir'), + 'prop_gbl': ObjType('prop_gbl', 'prop_gbl'), + 'prop_inst': ObjType('prop_inst', 'prop_inst'), + 'prop_sf': ObjType('prop_sf', 'prop_sf'), + 'prop_test': ObjType('prop_test', 'prop_test'), + 'prop_tgt': ObjType('prop_tgt', 'prop_tgt'), + 'manual': ObjType('manual', 'manual'), + } + directives = { + 'command': CMakeObject, + 'envvar': CMakeObject, + 'genex': CMakeGenexObject, + 'signature': CMakeSignatureObject, + 'variable': CMakeObject, + # Other `object_types` cannot be created except by the `CMakeTransform` + } + roles = { + 'cref': CMakeCRefRole(), + 'command': CMakeXRefRole(fix_parens=True, lowercase=True), + 'cpack_gen': CMakeXRefRole(), + 'envvar': CMakeXRefRole(), + 'generator': CMakeXRefRole(), + 'genex': CMakeXRefRole(), + 'guide': CMakeXRefRole(), + 'variable': CMakeXRefRole(), + 'module': CMakeXRefRole(), + 'policy': CMakeXRefRole(), + 'prop_cache': CMakeXRefRole(), + 'prop_dir': CMakeXRefRole(), + 'prop_gbl': CMakeXRefRole(), + 'prop_inst': CMakeXRefRole(), + 'prop_sf': CMakeXRefRole(), + 'prop_test': CMakeXRefRole(), + 'prop_tgt': CMakeXRefRole(), + 'manual': CMakeXRefRole(), + } + initial_data = { + 'objects': {}, # fullname -> ObjectEntry + } + + def clear_doc(self, docname): + to_clear = set() + for fullname, obj in self.data['objects'].items(): + if obj.docname == docname: + to_clear.add(fullname) + for fullname in to_clear: + del self.data['objects'][fullname] + + def merge_domaindata(self, docnames, otherdata): + """Merge domaindata from the workers/chunks when they return. + + Called once per parallelization chunk. + Only used when sphinx is run in parallel mode. + + :param docnames: a Set of the docnames that are part of the current + chunk to merge + :param otherdata: the partial data calculated by the current chunk + """ + for refname, obj in otherdata['objects'].items(): + if obj.docname in docnames: + self.data['objects'][refname] = obj + + def resolve_xref(self, env, fromdocname, builder, + typ, target, node, contnode): + targetid = f'{typ}:{target}' + obj = self.data['objects'].get(targetid) + + if obj is None and typ == 'command': + # If 'command(args)' wasn't found, try just 'command'. + # TODO: remove this fallback? warn? + # logger.warning(f'no match for {targetid}') + command = target.split('(')[0] + targetid = f'{typ}:{command}' + obj = self.data['objects'].get(targetid) + + if obj is None: + # TODO: warn somehow? + return None + + return make_refnode(builder, fromdocname, obj.docname, obj.node_id, + contnode, target) + + def note_object(self, objtype: str, name: str, target_id: str, + node_id: str, location: Any = None): + if target_id in self.data['objects']: + other = self.data['objects'][target_id].docname + logger.warning( + f'CMake object {target_id!r} also described in {other!r}', + location=location) + + self.data['objects'][target_id] = ObjectEntry( + self.env.docname, objtype, node_id, name) + + def get_objects(self): + for refname, obj in self.data['objects'].items(): + yield (refname, refname, obj.objtype, obj.docname, obj.node_id, 1) + + +def setup(app): + app.add_directive('cmake-module', CMakeModule) + app.add_transform(CMakeTransform) + app.add_transform(CMakeXRefTransform) + app.add_domain(CMakeDomain) + return {"parallel_read_safe": True} diff --git a/sphinxcontrib/moderncmakedomain/colors.py b/sphinxcontrib/moderncmakedomain/colors.py new file mode 100644 index 0000000..dae0063 --- /dev/null +++ b/sphinxcontrib/moderncmakedomain/colors.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from pygments.style import Style +from pygments.token import Name, Comment, String, Number, Operator, Whitespace + +class CMakeTemplateStyle(Style): + """ + for more token names, see pygments/styles.default + """ + + background_color = "#f8f8f8" + default_style = "" + + styles = { + Whitespace: "#bbbbbb", + Comment: "italic #408080", + Operator: "#555555", + String: "#217A21", + Number: "#105030", + Name.Builtin: "#333333", # anything lowercase + Name.Function: "#007020", # function + Name.Variable: "#1080B0", # <..> + Name.Tag: "#bb60d5", # ${..} + Name.Constant: "#4070a0", # uppercase only + Name.Entity: "italic #70A020", # @..@ + Name.Attribute: "#906060", # paths, URLs + Name.Label: "#A0A000", # anything left over + Name.Exception: "bold #FF0000", # for debugging only + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6a0c552 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from sphinx.testing.path import path + +pytest_plugins = 'sphinx.testing.fixtures' + +@pytest.fixture(scope="session") +def rootdir(): + return path(__file__).parent.abspath() / "roots" diff --git a/tests/roots/test-root/conf.py b/tests/roots/test-root/conf.py new file mode 100644 index 0000000..0aa6266 --- /dev/null +++ b/tests/roots/test-root/conf.py @@ -0,0 +1 @@ +extensions = ["sphinxcontrib.moderncmakedomain"] diff --git a/tests/roots/test-root/external.rst b/tests/roots/test-root/external.rst new file mode 100644 index 0000000..f09b874 --- /dev/null +++ b/tests/roots/test-root/external.rst @@ -0,0 +1,5 @@ +External +-------- + + +An external reference is :cmake:command:`find_program`. diff --git a/tests/roots/test-root/index.rst b/tests/roots/test-root/index.rst new file mode 100644 index 0000000..0257f55 --- /dev/null +++ b/tests/roots/test-root/index.rst @@ -0,0 +1,7 @@ +.. toctree:: + + local + external + parallel + padding + more diff --git a/tests/roots/test-root/local.rst b/tests/roots/test-root/local.rst new file mode 100644 index 0000000..8efcf20 --- /dev/null +++ b/tests/roots/test-root/local.rst @@ -0,0 +1,17 @@ +Local +----- + + +Some CMake +========== + +.. cmake:variable:: MYVAR + +.. cmake:variable:: MYVAR2 + +.. cmake:command:: somecommand + +Some references +=============== + +Using :cmake:variable:`MYVAR`. Also :cmake:command:`somecommand`. diff --git a/tests/roots/test-root/more.rst b/tests/roots/test-root/more.rst new file mode 100644 index 0000000..8572267 --- /dev/null +++ b/tests/roots/test-root/more.rst @@ -0,0 +1,5 @@ +More +---- + + +Even more padding to get to 5 pages. diff --git a/tests/roots/test-root/padding.rst b/tests/roots/test-root/padding.rst new file mode 100644 index 0000000..222decf --- /dev/null +++ b/tests/roots/test-root/padding.rst @@ -0,0 +1,4 @@ +Padding +------- + +Sphinx requires 5 or more pages to enable parallel builds. diff --git a/tests/roots/test-root/parallel.rst b/tests/roots/test-root/parallel.rst new file mode 100644 index 0000000..1f81e4f --- /dev/null +++ b/tests/roots/test-root/parallel.rst @@ -0,0 +1,16 @@ + +Parallel +-------- + +An extra file to make sure parallel is tested. + +Some CMake +========== + +.. cmake:variable:: OTHERVAR + +Some references +=============== + + +Using :cmake:variable:`OTHERVAR`. diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..659bed4 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,27 @@ +import pytest + +@pytest.mark.parametrize("parallel", [0, 1, 2]) +@pytest.mark.sphinx( + "html", + freshenv=True, + confoverrides={"html_baseurl": "https://example.org/docs/", "language": "en"}, +) +def test_simple_html(app, status, warning, parallel): + app.warningiserror = True + app.parallel = parallel + app.build() + local_pth = app.outdir / "local.html" + external_pth = app.outdir / "external.html" + + with open(str(local_pth), encoding="utf-8") as f: + local = f.read() + + with open(str(external_pth), encoding="utf-8") as f: + external = f.read() + + print(local) + assert 'href="#variable:MYVAR"' in local + assert 'id="variable:MYVAR"' in local + + + diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..7b1c89b --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,7 @@ +"""Version tests.""" +from sphinxcontrib.moderncmakedomain import __version__ + + +def test_version(): + """Test that version has at least 3 parts.""" + assert __version__.count('.') >= 2 |