From da4bb5aa42fc86a3018791e4e368b637742dd843 Mon Sep 17 00:00:00 2001 From: Ashley Whetter Date: Tue, 20 Jun 2023 19:50:10 -0700 Subject: [PATCH] WIP Initial implementation --- .github/workflows/main.yml | 25 + .gitignore | 10 + .readthedocs.yml | 13 + CHANGELOG.rst | 0 LICENSE.rst | 26 ++ README.rst | 103 +++++ doc/changes/.gitkeep | 0 doc/source/conf.py | 45 ++ doc/source/index.rst | 436 ++++++++++++++++++ pyproject.toml | 50 ++ src/graphql2sphinx/__init__.py | 65 +++ src/graphql2sphinx/_mapper.py | 44 ++ src/graphql2sphinx/_objects.py | 93 ++++ src/graphql2sphinx/_parser.py | 415 +++++++++++++++++ src/graphql2sphinx/templates/directive.rst | 5 + src/graphql2sphinx/templates/enum.rst | 10 + src/graphql2sphinx/templates/enum_value.rst | 5 + src/graphql2sphinx/templates/input.rst | 9 + src/graphql2sphinx/templates/input_field.rst | 5 + src/graphql2sphinx/templates/interface.rst | 9 + .../templates/interface_field.rst | 5 + src/graphql2sphinx/templates/scalar.rst | 5 + src/graphql2sphinx/templates/schema.rst | 9 + src/graphql2sphinx/templates/type.rst | 9 + src/graphql2sphinx/templates/type_field.rst | 5 + src/graphql2sphinx/templates/union.rst | 5 + tests/__init__.py | 0 tests/conftest.py | 59 +++ tests/fixtures/arguments.graphql | 95 ++++ tests/fixtures/arguments.rst | 62 +++ tests/fixtures/conf.py | 29 ++ tests/fixtures/directives.graphql | 23 + tests/fixtures/directives.rst | 16 + tests/fixtures/enums.graphql | 18 + tests/fixtures/enums.rst | 29 ++ tests/fixtures/index.rst | 9 + tests/fixtures/inputs.graphql | 19 + tests/fixtures/inputs.rst | 33 ++ tests/fixtures/interfaces.graphql | 19 + tests/fixtures/interfaces.rst | 33 ++ tests/fixtures/scalars.graphql | 11 + tests/fixtures/scalars.rst | 20 + tests/fixtures/type_objects.graphql | 19 + tests/fixtures/type_objects.rst | 33 ++ tests/fixtures/unions.graphql | 19 + tests/fixtures/unions.rst | 22 + tests/test_integration.py | 360 +++++++++++++++ tests/test_parser.py | 372 +++++++++++++++ tox.ini | 69 +++ 49 files changed, 2775 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .readthedocs.yml create mode 100644 CHANGELOG.rst create mode 100644 LICENSE.rst create mode 100644 README.rst create mode 100644 doc/changes/.gitkeep create mode 100644 doc/source/conf.py create mode 100644 doc/source/index.rst create mode 100644 pyproject.toml create mode 100644 src/graphql2sphinx/__init__.py create mode 100644 src/graphql2sphinx/_mapper.py create mode 100644 src/graphql2sphinx/_objects.py create mode 100644 src/graphql2sphinx/_parser.py create mode 100644 src/graphql2sphinx/templates/directive.rst create mode 100644 src/graphql2sphinx/templates/enum.rst create mode 100644 src/graphql2sphinx/templates/enum_value.rst create mode 100644 src/graphql2sphinx/templates/input.rst create mode 100644 src/graphql2sphinx/templates/input_field.rst create mode 100644 src/graphql2sphinx/templates/interface.rst create mode 100644 src/graphql2sphinx/templates/interface_field.rst create mode 100644 src/graphql2sphinx/templates/scalar.rst create mode 100644 src/graphql2sphinx/templates/schema.rst create mode 100644 src/graphql2sphinx/templates/type.rst create mode 100644 src/graphql2sphinx/templates/type_field.rst create mode 100644 src/graphql2sphinx/templates/union.rst create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/arguments.graphql create mode 100644 tests/fixtures/arguments.rst create mode 100644 tests/fixtures/conf.py create mode 100644 tests/fixtures/directives.graphql create mode 100644 tests/fixtures/directives.rst create mode 100644 tests/fixtures/enums.graphql create mode 100644 tests/fixtures/enums.rst create mode 100644 tests/fixtures/index.rst create mode 100644 tests/fixtures/inputs.graphql create mode 100644 tests/fixtures/inputs.rst create mode 100644 tests/fixtures/interfaces.graphql create mode 100644 tests/fixtures/interfaces.rst create mode 100644 tests/fixtures/scalars.graphql create mode 100644 tests/fixtures/scalars.rst create mode 100644 tests/fixtures/type_objects.graphql create mode 100644 tests/fixtures/type_objects.rst create mode 100644 tests/fixtures/unions.graphql create mode 100644 tests/fixtures/unions.rst create mode 100644 tests/test_integration.py create mode 100644 tests/test_parser.py create mode 100644 tox.ini diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c067441 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,25 @@ +name: tests + +on: + - push + - pull_request + +jobs: + test: + strategy: + matrix: + python-version: [3.8, 3.9, '3.10', 3.11] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install tox tox-gh-actions + - name: Run tests + run: tox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d25895 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.egg-info +*.pyc +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.tox/ +_build +build/ +dist/ +__pycache__/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..d7fcbf8 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +python: + install: + - method: pip + path: . + extra_requirements: + - doc diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE.rst b/LICENSE.rst new file mode 100644 index 0000000..07ebcc0 --- /dev/null +++ b/LICENSE.rst @@ -0,0 +1,26 @@ +The MIT License (MIT) +===================== + +Copyright (c) 2023 Ashley Whetter + +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/README.rst b/README.rst new file mode 100644 index 0000000..bef86c4 --- /dev/null +++ b/README.rst @@ -0,0 +1,103 @@ +graphql2sphinx +============== + +.. image:: https://readthedocs.org/projects/graphql2sphinx/badge/?version=latest + :target: https://graphql2sphinx.readthedocs.org + :alt: Documentation + +.. image:: https://github.com/AWhetter/graphql2sphinx/actions/workflows/main.yml/badge.svg?branch=main + :target: https://github.com/AWhetter/graphql2sphinx/actions/workflows/main.yml?query=branch%3Amain + :alt: Github Build Status + +.. image:: https://img.shields.io/pypi/v/graphql2sphinx.svg + :target: https://pypi.org/project/graphql2sphinx/ + :alt: PyPI Version + +.. image:: https://img.shields.io/pypi/pyversions/graphql2sphinx.svg + :target: https://pypi.org/project/graphql2sphinx/ + :alt: Supported Python Versions + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/python/black + :alt: Formatted with Black + +A Sphinx extension for automatically documenting GraphQL schemas. + + +Getting Started +--------------- + +The following steps will walk through how to add ``graphql2sphinx`` to an existing Sphinx project. +For instructions on how to set up a Sphinx project, +see Sphinx's documentation on +`Getting Started `_. + + +Installation +~~~~~~~~~~~~ + +``graphql2sphinx`` can be installed through pip: + +.. code-block:: bash + + pip install graphql2sphinx + +Next, add ``graphql2sphinx`` to the ``extensions`` list in your Sphinx project's `conf.py`. + +.. code-block:: python + + extensions.append("graphql2sphinx") + + +Usage +----- + +Each directive accepts a small snippet of the original schema. +For more detailed usage, see the documentation: +https://graphql2sphinx.readthedocs.io/en/latest/ + +TODO + +Contributing +------------ + + +Running the tests +~~~~~~~~~~~~~~~~~ + +Tests are executed through `tox `_. + +.. code-block:: bash + + tox + + +Code Style +~~~~~~~~~~ + +Code is formatted using `black `_. + +You can check your formatting using black's check mode: + +.. code-block:: bash + + tox -e formatting + +You can also get black to format your changes for you: + +.. code-block:: bash + + black graphql2sphinx.py tests/ + + +Versioning +---------- + +We use `SemVer `_ for versioning. For the versions available, see the `tags on this repository `_. + + +License +------- + +This project is licensed under the MIT License. +See the `LICENSE.rst `_ file for details. diff --git a/doc/changes/.gitkeep b/doc/changes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..157aaf6 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,45 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'graphql2sphinx' +copyright = '2023, Ashley Whetter' +author = 'Ashley Whetter' +release = '0.1.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'graphql2sphinx', + 'sphinx.ext.intersphinx', +] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'furo' +html_static_path = ['_static'] + + +# -- Options for intersphinx ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration + +intersphinx_mapping = { + 'sphinx': ('https://www.sphinx-doc.org/en/master', None), +} + + +def setup(app): + app.add_object_type('confval', 'confval', + objname='configuration value', + indextemplate='pair: %s; configuration value') diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..189d904 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,436 @@ +:mod:`graphqldomain` +==================== + +This extension provides a Sphinx domain for describing GraphQL schemas. + +In order to use this extension, +add :mod:`graphqldomain` to the :confval:`sphinx:extensions` +list in your :doc:`conf.py ` file. + +.. code-block:: python + + extensions = ["graphqldomain"] + + +Directives +---------- + +.. rst:directive:: .. gql:directive:: definition + + Describes a GraphQL directive defined in a schema. + + The ``definition`` argument is the definition of the directive, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Type-System.Directives). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``directive`` keyword. + + + For example: + + .. code-block:: rst + + .. gql:directive:: @slow(super: Boolean = false) on FIELD_DEFINITION | ARGUMENT_DEFINITION + + Indicates that the usage of this field or argument is slow, + and therefore queries with this field or argument should be made sparingly. + + :argument super: Whether usage will be super slow, or just a bit slow. + + + This will be rendered as: + + .. gql:directive:: @slow(super: Boolean = false) on FIELD_DEFINITION | ARGUMENT_DEFINITION + + Indicates that the usage of this field or argument is slow, + and therefore queries with this field or argument should be made sparingly. + + :argument super: Whether usage will be super slow, or just a bit slow. + +.. rst:directive:: .. gql:enum:: definition + + Describes a GraphQL enum defined in a schema. + + The ``definition`` argument is the definition of the enum, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Enums). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``enum`` keyword. + * The ``EnumValuesDefinition``. Enum values can be described with the + :rst:dir:`gql:enum:value` directive. + + + For example: + + .. code-block:: rst + + .. gql:enum:: CharacterCase + + The casing of a character. + + This will be rendered as: + + .. gql:enum:: CharacterCase + + The casing of a character. + + +.. rst:directive:: .. gql:enum:value:: definition + + Describes a GraphQL enum value defined on an enum in a schema. + + The ``definition`` argument is the definition of the enum value, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#EnumValueDefinition). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + + For example: + + .. code-block:: rst + + .. gql:enum:: CharacterCase + + The casing of a character. + + .. gql:enum:value:: UPPER + + Upper case. + + .. gql:enum:value:: LOWER + + Lower case. + + This will be rendered as: + + .. gql:enum:: CharacterCase + + The casing of a character. + + .. gql:enum:value:: UPPER + + Upper case. + + .. gql:enum:value:: LOWER + + Lower case. + + +.. rst:directive:: .. gql:input:: definition + + Describes a GraphQL input object defined in a schema. + + The ``definition`` argument is the definition of the input object, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Input-Objects). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``input`` keyword. + * The ``InputFieldDefinition``. Input values can be described with the + :rst:dir:`gql:input:value` directive. + + For example: + + .. code-block:: rst + + .. gql:input:: Point2D + + A point in a 2D coordinate system. + + This will be rendered as: + + .. gql:input:: Point2D + + A point in a 2D coordinate system. + + +.. rst:directive:: .. gql:input:field:: definition + + Describes a GraphQL input field defined on an input in a schema. + + The ``definition`` argument is the definition of the input field, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#InputValueDefinition). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + + For example: + + .. code-block:: rst + + .. gql:input:: Point2D + + A point in a 2D coordinate system. + + .. gql:input:field:: x: Float + + The ``x`` coordinate of the point. + + .. gql:input:field:: y: Float + + The ``y`` coordinate of the point. + + This will be rendered as: + + .. gql:input:: Point2D + + A point in a 2D coordinate system. + + .. gql:input:field:: x: Float + + The ``x`` coordinate of the point. + + .. gql:input:field:: y: Float + + The ``y`` coordinate of the point. + + +.. rst:directive:: .. gql:interface:: definition + + Describes a GraphQL interface defined on a schema. + + The ``definition`` argument is the definition of the interface, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Interfaces). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``interface`` keyword. + * The ``FieldsDefinition``. Interface fields can be described with the + :rst:dir:`gql:interface:field` directive. + + For example: + + .. code-block:: rst + + .. gql:interface:: NamedEntity + + An entity with a name. + + This will be rendered as: + + .. gql:interface:: NamedEntity + + An entity with a name. + + +.. rst:directive:: .. gql:interface:field:: definition + + Describes a GraphQL interface field defined on an interface in a schema. + + The ``definition`` argument is the definition of the interface field, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#FieldDefinition). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + + For example: + + .. code-block:: rst + + .. gql:interface:: NamedEntity + + An entity with a name. + + .. gql:interface:field:: name(lower: Boolean = false): String + + The name of the entity. + + :argument lower: Whether to lowercase the name or not. + + This will be rendered as: + + .. gql:interface:: NamedEntity + + An entity with a name. + + .. gql:interface:field:: name(lower: Boolean = false): String + + The name of the entity. + + :argument lower: Whether to lowercase the name or not. + + +.. rst:directive:: .. gql:scalar:: definition + + Describes a GraphQL scalar type defined on a schema. + + The ``definition`` argument is the definition of the scalar type, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Scalars). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``scalar`` keyword. + + For example: + + .. code-block:: rst + + .. gql:scalar:: Url + + A string that represents a valid URL. + + This will be rendered as: + + .. gql:scalar:: Url + + A string that represents a valid URL. + + +.. rst:directive:: .. gql:type:: definition + + Describes a GraphQL object type defined on a schema. + + The ``definition`` argument is the definition of the object type, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Objects). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``type`` keyword. + * The ``FieldsDefinition``. Interface fields can be described with the + :rst:dir:`gql:type:field` directive. + + For example: + + .. code-block:: rst + + .. gql:type:: Person implements NamedEntity + + A human person. + + This will be rendered as: + + .. gql:type:: Person implements NamedEntity + + A human person. + + +.. rst:directive:: .. gql:type:field:: definition + + Describes a GraphQL field defined on an object type in a schema. + + The ``definition`` argument is the definition of the type field, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#FieldDefinition). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``type`` keyword. + * The ``FieldsDefinition``. Interface fields can be described with the + :rst:dir:`gql:interface:value` directive. + + For example: + + .. code-block:: rst + + .. gql:type:: Person implements NamedEntity + + A human person. + + .. gql:type:field:: age: Int + + How old the person is in years. + + .. gql:type:field:: picture: Url + + This will be rendered as: + + .. gql:type:: Person implements NamedEntity + + A human person. + + .. gql:type:field:: age: Int + + How old the person is in years. + + .. gql:type:field:: picture: Url + + +.. rst:directive:: .. gql:union:: definition + + Describes a GraphQL union defined on a schema. + + The ``definition`` argument is the definition of the union, + using the format described in the GraphQL spec + (https://spec.graphql.org/June2018/#sec-Unions). + However it should not include the following: + + * The ``Description``, which goes into the body of this directive. + * The ``union`` keyword. + + For example: + + .. code-block:: rst + + .. gql:union:: Centre = Person | Point2D + + A possible centre of the universe. + + This will be rendered as: + + .. gql:union:: Centre = Person | Point2D + + A possible centre of the universe. + + +Roles +----- + +All GraphQL directives have a role with the same name that can be used to +refer to those objects. +For example a GraphQL ``type`` defined with the :rst:dir:`gql:type` directive +can be referred to using the :rst:role:`gql:type` role. + +.. rst:role:: directive + + Refers to a GraphQL directive defined with the :rst:dir:`gql:directive` rST directive. + +.. rst:role:: enum + + Refers to a GraphQL enum defined with the :rst:dir:`gql:enum` rST directive. + +.. rst:role:: enum:value + + Refers to a GraphQL enum value defined with the :rst:dir:`gql:enum:value` rST directive. + +.. rst:role:: input + + Refers to a GraphQL input defined with the :rst:dir:`gql:input` rST directive. + +.. rst:role:: input:field + + Refers to a GraphQL input field defined with the :rst:dir:`gql:input:field` rST directive. + +.. rst:role:: interface + + Refers to a GraphQL interface defined with the :rst:dir:`gql:interface` rST directive. + +.. rst:role:: interface:field + + Refers to a GraphQL interface field defined with the :rst:dir:`gql:interface:field` rST directive. + +.. rst:role:: scalar + + Refers to a GraphQL scalar defined with the :rst:dir:`gql:scalar` rST directive. + +.. rst:role:: type + + Refers to a GraphQL type defined with the :rst:dir:`gql:type` rST directive. + +.. rst:role:: type:field + + Refers to a GraphQL type field defined with the :rst:dir:`gql:type:field` rST directive. + +.. rst:role:: union + + Refers to a GraphQL union defined with the :rst:dir:`gql:union` rST directive. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d0a5e65 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "graphql2sphinx" +authors = [ + {name = "Ashley Whetter", email = "ashley@awhetter.co.uk"}, +] +readme = "README.rst" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Plugins", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Documentation :: Sphinx", +] +requires-python = ">=3.8" +dynamic = ["version", "description"] +dependencies = [ + "graphqldomain>=0.1.0", + "sphinx>=4.0", +] + +[project.optional-dependencies] +doc = [ + "furo", + "sphinx", +] + +[project.urls] +Source = "https://github.com/AWhetter/graphql2sphinx" +Documentation = "https://graphql2sphinx.readthedocs.org" + +[tool.pylint] +disable = "R,unused-argument" + +[tool.towncrier] +directory = "doc/changes" +filename = "CHANGELOG.rst" +package = "graphql2sphinx" +title_format = "graphql2sphinx v{version} ({project_date})" diff --git a/src/graphql2sphinx/__init__.py b/src/graphql2sphinx/__init__.py new file mode 100644 index 0000000..7423b8f --- /dev/null +++ b/src/graphql2sphinx/__init__.py @@ -0,0 +1,65 @@ +"""A Sphinx extension for automatically documenting GraphQL schemas.""" +import os + +import sphinx.util.logging + +from ._mapper import Mapper + +__version__ = "0.1.0" +LOGGER = sphinx.util.logging.getLogger(__name__) + + +def builder_inited(app): + template_dir = app.config.graphql2sphinx_template_dir + if template_dir and not os.path.isabs(template_dir): + template_dir = os.path.join(app.srcdir, app.config.graphql2sphinx_template_dir) + + sphinx_mapper_obj = Mapper(app, template_dir=template_dir, url_root=url_root) + + if ".rst" in app.config.source_suffix: + out_suffix = ".rst" + elif ".txt" in app.config.source_suffix: + out_suffix = ".txt" + else: + # Fallback to first suffix listed + out_suffix = next(iter(app.config.source_suffix)) + + if sphinx_mapper_obj.load( + patterns=file_patterns, dirs=normalised_dirs, ignore=ignore_patterns + ): + sphinx_mapper_obj.map(options=app.config.autoapi_options) + sphinx_mapper_obj.output_rst(root=normalized_root, source_suffix=out_suffix) + + +def build_finished(app, exception): + if not app.config.graphql2sphinx_keep_files: + normalised_root = os.path.normpath( + os.path.join(app.srcdir, app.config.autoapi_root) + ) + if app.verbosity > 1: + LOGGER.info(bold("[graphql2sphinx] ") + darkgreen("Cleaning generated .rst files")) + shutil.rmtree(normalised_root) + + +def setup(app): + app.connect("builder-inited", builder_inited) + app.connect("build-finished", build_finished) + app.add_config_value("autoapi_root", API_ROOT, "html") + app.add_config_value("autoapi_ignore", [], "html") + app.add_config_value("autoapi_options", _DEFAULT_OPTIONS, "html") + app.add_config_value("autoapi_member_order", "bysource", "html") + app.add_config_value("autoapi_file_patterns", None, "html") + app.add_config_value("autoapi_dirs", [], "html") + app.add_config_value("graphql2sphinx_keep_files", False, "html") + app.add_config_value("autoapi_add_toctree_entry", True, "html") + app.add_config_value("graphql2sphinx_template_dir", None, "html") + app.add_config_value("autoapi_include_summaries", None, "html") + app.add_config_value("autoapi_python_use_implicit_namespaces", False, "html") + app.add_config_value("autoapi_python_class_content", "class", "html") + app.add_config_value("autoapi_generate_api_docs", True, "html") + app.add_config_value("autoapi_prepare_jinja_env", None, "html") + + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/graphql2sphinx/_mapper.py b/src/graphql2sphinx/_mapper.py new file mode 100644 index 0000000..58e4bf9 --- /dev/null +++ b/src/graphql2sphinx/_mapper.py @@ -0,0 +1,44 @@ +import pathlib + +from sphinx.application import Sphinx +import sphinx.util + + +class Mapper: + def __init__(self, app: Sphinx): + self.app = app + + here = pathlib.Path(__file__).parent + template_paths = [here / "templates"] + + self.jinja_env = Environment( + loader=FileSystemLoader(template_paths), + trim_blocks=True, + lstrip_blocks=True, + ) + + def load(self, patterns, dirs, ignore=None): + """ + Load objects from the filesystem into the ``paths`` dictionary. + + """ + paths = list(self.find_files(patterns=patterns, dirs=dirs, ignore=ignore)) + for path in sphinx.util.status_iterator( + paths, bold("[graphql2sphinx] Reading files... "), "darkgreen", len(paths) + ): + data = self.read_file(path=path) + if data: + self.paths[path] = data + + return True + + def map(self, options=None): + """Trigger find of serialized sources and build objects""" + for _, data in sphinx.util.status_iterator( + self.paths.items(), + bold("[graphql2sphinx] ") + "Mapping Data... ", + length=len(self.paths), + stringify_func=(lambda x: x[0]), + ): + for obj in self.create_class(data, options=options): + self.add_object(obj) diff --git a/src/graphql2sphinx/_objects.py b/src/graphql2sphinx/_objects.py new file mode 100644 index 0000000..2482066 --- /dev/null +++ b/src/graphql2sphinx/_objects.py @@ -0,0 +1,93 @@ +import sphinx +import sphinx.util.logging + +LOGGER = sphinx.util.logging.getLogger(__name__) + + +class GraphQLObject: + type: str + + def __init__(self, children, description, signature, line=None) -> None: + self.children = children + self.description = description + self.signature = signature + self.line = line + + def __repr__(self): + type_ = type(self) + module = type_.__module__ + qualname = type_.__qualname__ + return f"<{module}.{qualname}({repr(self.signature)}) at {hex(id(self))}>" + + def get_context_data(self): + return { + "obj": self, + "sphinx_version": sphinx.version_info, + } + + def render(self): + LOGGER.log(self._RENDER_LOG_LEVEL, "Rendering %s", self.id) + + template = self.jinja_env.get_template(f"{self.type}.rst") + ctx = self.get_context_data() + return template.render(**ctx) + + def __eq__(self, other): + if not isinstance(other, GraphQLObject): + return NotImplemented + + return ( + self.__class__ == other.__class__ + and self.children == other.children + and self.description == other.description + and self.signature == other.signature + and self.line == other.line + ) + + +class GraphQLDirective(GraphQLObject): + type = "directive" + + +class GraphQLEnum(GraphQLObject): + type = "enum" + + +class GraphQLEnumValue(GraphQLObject): + type = "enum_value" + + +class GraphQLInput(GraphQLObject): + type = "input" + + +class GraphQLInputField(GraphQLObject): + type = "input_field" + + +class GraphQLInterface(GraphQLObject): + type = "interface" + + +class GraphQLInterfaceField(GraphQLObject): + type = "interface_field" + + +class GraphQLScalar(GraphQLObject): + type = "scalar" + + +class GraphQLSchema(GraphQLObject): + type = "schema" + + +class GraphQLType(GraphQLObject): + type = "type" + + +class GraphQLTypeField(GraphQLObject): + type = "type_field" + + +class GraphQLUnion(GraphQLObject): + type = "union" diff --git a/src/graphql2sphinx/_parser.py b/src/graphql2sphinx/_parser.py new file mode 100644 index 0000000..d3377ab --- /dev/null +++ b/src/graphql2sphinx/_parser.py @@ -0,0 +1,415 @@ +import operator +from typing import Optional, Sequence, Type, TypeVar + +import graphql.type +from graphql.language import ast as gql_ast + +from ._objects import ( + GraphQLDirective, + GraphQLEnum, + GraphQLEnumValue, + GraphQLInput, + GraphQLInputField, + GraphQLInterface, + GraphQLInterfaceField, + GraphQLScalar, + GraphQLSchema, + GraphQLType, + GraphQLTypeField, + GraphQLUnion, +) + +T = TypeVar("T", GraphQLTypeField, GraphQLInterfaceField) + +def _unparse_directives( + ast_nodes: Sequence[gql_ast.ConstDirectiveNode] +) -> str: + result = "" + + for directive_node in ast_nodes: + result += " @" + + result += directive_node.name.value + result += _unparse_const_arguments(directive_node.arguments) + + return result + + +def _unparse_const_arguments( + ast_nodes: Sequence[gql_ast.ConstArgumentNode] +) -> str: + result = "" + + if not ast_nodes: + return result + + result += "(" + + for i, argument_node in enumerate(ast_nodes): + if i != 0: + result += ", " + + result += argument_node.name.value + result += ": " + result += _unparse_literal(argument_node.value) + + result += ")" + return result + + +def _unparse_input_values( + ast_nodes: Sequence[gql_ast.InputValueDefinitionNode], +) -> str: + result = "" + if not ast_nodes: + return result + + result += "(" + + for i, argument_node in enumerate(ast_nodes): + if i != 0: + result += ", " + + result += argument_node.name.value + result += ": " + result += _unparse_type_reference(argument_node.type) + result += _unparse_default_value(argument_node.default_value) + result += _unparse_directives(argument_node.directives) + + result += ")" + return result + +def _unparse_default_value( + ast_nodes: Optional[gql_ast.ConstValueNode] +) -> str: + result = "" + if not ast_nodes: + return result + + result += " = " + result += _unparse_literal(ast_nodes) + return result + +def _unparse_literal( + ast_nodes: Optional[gql_ast.ConstValueNode] +) -> str: + result = "" + + if isinstance(ast_nodes, gql_ast.ListValueNode): + result += "[" + for i, item_node in enumerate(ast_nodes.values): + if i != 0: + result += ", " + + result += _unparse_literal(item_node) + + result += "]" + + elif isinstance(ast_nodes, gql_ast.ObjectValueNode): + result += "{" + for i, field_node in enumerate(ast_nodes.fields): + if i != 0: + result += ", " + + result += field_node.name.value + result += ": " + result += _unparse_literal(field_node.value) + + result += "}" + + elif isinstance(ast_nodes, (gql_ast.IntValueNode, gql_ast.FloatValueNode)): + result += str(ast_nodes.value) + elif isinstance(ast_nodes, gql_ast.StringValueNode): + result += '"' + result += ast_nodes.value + result += '"' + elif isinstance(ast_nodes, gql_ast.BooleanValueNode): + result += str(ast_nodes.value).lower() + elif isinstance(ast_nodes, gql_ast.NullValueNode): + result += "null" + elif isinstance(ast_nodes, gql_ast.EnumValueNode): + result += ast_nodes.value + # Variable values are a valid literal but not in schemas + else: + raise TypeError(f"Unknown literal node type '{type(ast_nodes)}'") + + return result + + +def _unparse_type_reference( + ast_node: gql_ast.TypeNode, +) -> str: + result = "" + + if isinstance(ast_node, gql_ast.NamedTypeNode): + result += ast_node.name.value + elif isinstance(ast_node, gql_ast.NonNullTypeNode): + result += _unparse_type_reference(ast_node.type) + result += "!" + elif isinstance(ast_node, gql_ast.ListTypeNode): + result += "[" + result += _unparse_type_reference(ast_node.type) + result += "]" + else: + raise TypeError(f"Unknown type node '{type(ast_node)}") + + return result + + +class Parser: + _DEFAULT_TYPES = { + "Int", "Float", "String", "Boolean", "ID", + } + """The names of all default types. + + As documented in https://spec.graphql.org/June2018/#sec-Scalars + """ + + def _parse_directive(self, node: graphql.type.GraphQLDirective) -> GraphQLDirective: + name = node.name + + arguments = "" + if node.ast_node.arguments: + arguments = _unparse_input_values(node.ast_node.arguments) + + locations = " | ".join(location.name for location in node.locations) + + children = [] + description = node.description + signature = f"@{name}{arguments} on {locations}" + line = node.ast_node.loc.start_token.line + return GraphQLDirective(children, description, signature, line) + + def _parse_enumtype(self, node: graphql.type.GraphQLEnumType) -> GraphQLEnum: + name = node.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + children = [] + for value_name, value in node.values.items(): + obj = self._parse_enumvalue(value_name, value) + children.append(obj) + + children.sort() + description = node.description + signature = f"{name}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLEnum(children, description, signature, line) + + def _parse_enumvalue(self, name: str, node: graphql.type.GraphQLEnumValue) -> GraphQLEnumValue: + directives = "" + if node.ast_node.directives: + directives = _unparse_directives(node.ast_node.directives) + + children = [] + description = node.description + signature = f"{name}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLEnumValue(children, description, signature, line) + + def _parse_field(self, name: str, obj_type: Type[T], node: graphql.type.GraphQLField) -> T: + arguments = "" + if node.ast_node.arguments: + arguments = _unparse_input_values(node.ast_node.arguments) + + type_ = _unparse_type_reference(node.ast_node.type) + + directives = "" + if node.ast_node.directives: + directives = _unparse_directives(node.ast_node.directives) + + children = [] + description = node.description + signature = f"{name}{arguments}: {type_}{directives}" + line = node.ast_node.loc.start_token.line + return obj_type(children, description, signature, line) + + def _parse_type_field(self, name: str, node: graphql.type.GraphQLField) -> GraphQLTypeField: + return self._parse_field(name, GraphQLTypeField, node) + + def _parse_interface_field(self, name: str, node: graphql.type.GraphQLField) -> GraphQLInterfaceField: + return self._parse_field(name, GraphQLInterfaceField, node) + + def _parse_input_field(self, name: str, node: graphql.type.GraphQLInputField) -> GraphQLInputField: + type_ = _unparse_type_reference(node.ast_node.type) + + default_value = "" + if node.ast_node.default_value: + default_value = _unparse_default_value(node.ast_node.default_value) + + directives = "" + if node.ast_node.directives: + directives = _unparse_directives(node.ast_node.directives) + + children = [] + description = node.description + signature = f"{name}: {type_}{default_value}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLInputField(children, description, signature, line) + + def _parse_inputobjecttype(self, node: graphql.type.GraphQLInputObjectType) -> GraphQLInput: + name = node.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + children = [] + for field_name, field in node.fields.items(): + obj = self._parse_input_field(field_name, field) + children.append(obj) + + children.sort(key=operator.attrgetter("line")) + description = node.description + signature = f"{name}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLInput(children, description, signature, line) + + def _parse_interfacetype(self, node: graphql.type.GraphQLInterfaceType) -> GraphQLInput: + name = node.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + children = [] + for field_name, field in node.fields.items(): + obj = self._parse_interface_field(field_name, field) + children.append(obj) + + children.sort(key=operator.attrgetter("line")) + description = node.description + signature = f"{name}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLInterface(children, description, signature, line) + + def _parse_named_type(self, node: graphql.type.GraphQLNamedType): + if graphql.type.is_enum_type(node): + return self._parse_enumtype(node) + + if graphql.type.is_input_object_type(node): + return self._parse_inputobjecttype(node) + + if graphql.type.is_interface_type(node): + return self._parse_interfacetype(node) + + if graphql.type.is_object_type(node): + return self._parse_objecttype(node) + + if graphql.type.is_scalar_type(node): + return self._parse_scalartype(node) + + if graphql.type.is_union_type(node): + return self._parse_uniontype(node) + + raise TypeError(f"Unknown named type: {type(node)}") + + def _parse_objecttype(self, node: graphql.type.GraphQLObjectType) -> GraphQLType: + name = node.name + + interfaces = "" + if node.interfaces: + interfaces = " " + for i, interface in node.interfaces: + if i != 0: + interfaces += " &" + + interfaces += interface.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + children = [] + for field_name, field in node.fields.items(): + obj = self._parse_type_field(field_name, field) + children.append(obj) + + children.sort(key=operator.attrgetter("line")) + description = node.description + signature = f"{name}{interfaces}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLType(children, description, signature, line) + + def _parse_scalartype(self, node: graphql.type.GraphQLScalarType) -> GraphQLScalar: + name = node.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + children = [] + description = node.description + signature = f"{name}{directives}" + line = node.ast_node.loc.start_token.line + return GraphQLScalar(children, description, signature, line) + + def _parse_schema(self, node: graphql.type.GraphQLSchema) -> GraphQLSchema: + children = [] + for type_name, type_ in node.type_map.items(): + if type_name in self._DEFAULT_TYPES or type_name.startswith("__"): + continue + + obj = self._parse_named_type(type_) + children.append(obj) + + for directive in node.directives: + # Don't document the default directives + if not directive.ast_node: + continue + + obj = self._parse_directive(directive) + children.append(obj) + + children.sort(key=operator.attrgetter("line")) + description = node.description + signature = "" + return GraphQLSchema(children, description, signature) + + def _parse_uniontype(self, node: graphql.type.GraphQLUnionType) -> GraphQLUnion: + name = node.name + + directives = "" + all_directives = list(node.ast_node.directives) + for ast_node in node.extension_ast_nodes: + all_directives.extend(ast_node.directives) + + if all_directives: + directives = _unparse_directives(all_directives) + + types = " | ".join(type_.name for type_ in node.types) + + children = [] + description = node.description + signature = f"{name}{directives} = {types}" + line = node.ast_node.loc.start_token.line + return GraphQLUnion(children, description, signature, line) + + def parse(self, node: graphql.type.GraphQLSchema) -> GraphQLSchema: + return self._parse_schema(node) + + def parse_from_source(self, source: str) -> GraphQLSchema: + schema = graphql.utilities.build_schema(source) + return self.parse(schema) diff --git a/src/graphql2sphinx/templates/directive.rst b/src/graphql2sphinx/templates/directive.rst new file mode 100644 index 0000000..e01eacd --- /dev/null +++ b/src/graphql2sphinx/templates/directive.rst @@ -0,0 +1,5 @@ +.. gql:directive:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/enum.rst b/src/graphql2sphinx/templates/enum.rst new file mode 100644 index 0000000..e8e3c73 --- /dev/null +++ b/src/graphql2sphinx/templates/enum.rst @@ -0,0 +1,10 @@ +.. gql:enum:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} + + {% for child in obj.children %} + {{ child.render()|indent(4) }} + {% endfor %} + diff --git a/src/graphql2sphinx/templates/enum_value.rst b/src/graphql2sphinx/templates/enum_value.rst new file mode 100644 index 0000000..ab983ea --- /dev/null +++ b/src/graphql2sphinx/templates/enum_value.rst @@ -0,0 +1,5 @@ +.. gql:enum:value:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/input.rst b/src/graphql2sphinx/templates/input.rst new file mode 100644 index 0000000..24c8b4c --- /dev/null +++ b/src/graphql2sphinx/templates/input.rst @@ -0,0 +1,9 @@ +.. gql:input:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} + + {% for child in obj.children %} + {{ child.render()|indent(4) }} + {% endfor %} diff --git a/src/graphql2sphinx/templates/input_field.rst b/src/graphql2sphinx/templates/input_field.rst new file mode 100644 index 0000000..8fb06b2 --- /dev/null +++ b/src/graphql2sphinx/templates/input_field.rst @@ -0,0 +1,5 @@ +.. gql:input:field:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/interface.rst b/src/graphql2sphinx/templates/interface.rst new file mode 100644 index 0000000..2cc4e72 --- /dev/null +++ b/src/graphql2sphinx/templates/interface.rst @@ -0,0 +1,9 @@ +.. gql:interface:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} + + {% for child in obj.children %} + {{ child.render()|indent(4) }} + {% endfor %} diff --git a/src/graphql2sphinx/templates/interface_field.rst b/src/graphql2sphinx/templates/interface_field.rst new file mode 100644 index 0000000..2f73265 --- /dev/null +++ b/src/graphql2sphinx/templates/interface_field.rst @@ -0,0 +1,5 @@ +.. gql:interface:field:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/scalar.rst b/src/graphql2sphinx/templates/scalar.rst new file mode 100644 index 0000000..f5e3da5 --- /dev/null +++ b/src/graphql2sphinx/templates/scalar.rst @@ -0,0 +1,5 @@ +.. gql:scalar:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/schema.rst b/src/graphql2sphinx/templates/schema.rst new file mode 100644 index 0000000..bf49e8c --- /dev/null +++ b/src/graphql2sphinx/templates/schema.rst @@ -0,0 +1,9 @@ +.. gql:schema:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} + + {% for child in obj.children %} + {{ child.render()|indent(4) }} + {% endfor %} diff --git a/src/graphql2sphinx/templates/type.rst b/src/graphql2sphinx/templates/type.rst new file mode 100644 index 0000000..51acb25 --- /dev/null +++ b/src/graphql2sphinx/templates/type.rst @@ -0,0 +1,9 @@ +.. gql:type:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} + + {% for child in obj.children %} + {{ child.render()|indent(4) }} + {% endfor %} diff --git a/src/graphql2sphinx/templates/type_field.rst b/src/graphql2sphinx/templates/type_field.rst new file mode 100644 index 0000000..1abc363 --- /dev/null +++ b/src/graphql2sphinx/templates/type_field.rst @@ -0,0 +1,5 @@ +.. gql:type:field:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/src/graphql2sphinx/templates/union.rst b/src/graphql2sphinx/templates/union.rst new file mode 100644 index 0000000..cfcf3c8 --- /dev/null +++ b/src/graphql2sphinx/templates/union.rst @@ -0,0 +1,5 @@ +.. gql:union:: {{ obj.signature }} + + {% if obj.docstring %} + {{ obj.description|indent(4) }} + {% endif %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8f49f2a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +from graphql2sphinx import _objects + +def _explain(left, right): + result = [] + + if not (isinstance(left, _objects.GraphQLObject) and isinstance(right, _objects.GraphQLObject)): + return [] + + if not type(left) == type(right): + return [f"because left is a {type(left).__name__} and right is a {type(right).__name__}"] + + if left.children != right.children: + len_left = len(left.children) + len_right = len(right.children) + for i in range(min(len_left, len_right)): + if left.children[i] != right.children[i]: + left_child = left.children[i] + right_child = right.children[i] + + result.append(f"because children differ at index {i} diff: {left_child!r} != {right_child!r}") + nested_lines = _explain(left_child, right_child) + for nested_line in nested_lines: + result.append(f" {nested_line}") + break + else: + len_diff = len_left - len_right + if len_diff: + result.append(f"because left has {len_left} children and right has {len_right}") + + if left.description != right.description: + if result: + result.append(f"and descriptions differ: {repr(left.description)} != {repr(right.description)}") + else: + result.append(f"because descriptions differ: {repr(left.description)} != {repr(right.description)}") + + if left.signature != right.signature: + if result: + result.append(f"and signatures differ: {repr(left.signature)} != {repr(right.signature)}") + else: + result.append(f"because signatures differ: {repr(left.signature)} != {repr(right.signature)}") + + if left.line != right.line: + if result: + result.append(f"and source line numbers differ: {repr(left.line)} != {repr(right.line)}") + else: + result.append(f"because source line numbers differ: {repr(left.line)} != {repr(right.line)}") + + return result + + +def pytest_assertrepr_compare(op, left, right): + if isinstance(left, _objects.GraphQLObject) and isinstance(right, _objects.GraphQLObject) and op == "==": + summary = f"{repr(left)} {op} {repr(right)}" + explanation = _explain(left, right) + + if not explanation: + return None + + return [summary] + explanation diff --git a/tests/fixtures/arguments.graphql b/tests/fixtures/arguments.graphql new file mode 100644 index 0000000..8bec23e --- /dev/null +++ b/tests/fixtures/arguments.graphql @@ -0,0 +1,95 @@ +""" +A directive to apply to arguments +""" +directive @directiveA on ARGUMENT_DEFINITION + +directive @directiveB(name1: Int, name2: Int) on ARGUMENT_DEFINITION + +enum enum1 { + ENUMVALUE +} + +input input1 { + one: Int + two: Int +} + +""" +A type to test different argument configurations +""" +type TestArgumentType { + """ + fieldA1 tests parsing with multiple arguments + """ + fieldA1( + "name1 tests that arguments can be documented" + name1: Int + "name2 tests that arguments can be documented" + name2: input1 + ): String + + """ + fieldB1 tests parsing with an argument directive + """ + fieldB1(name1: input1 @directiveA): String + + """ + fieldB2 tests parsing with an argument directive that has const arguments + """ + fieldB2(name1: input1 @directiveB(name1: 1, name2: 2)): String + + """ + fieldC1 tests parsing with an argument that has a default integer value + """ + fieldC1(name1: Int = 600): String + + """ + fieldC2 tests parsing with an argument that has a default float value + """ + fieldC2(name1: Float = 1.5): String + + """ + fieldC3 tests parsing with an argument that has a default string value + """ + fieldC3(name1: String = "mystring"): String + + """ + fieldC4 tests parsing with an argument that has a default boolean value + """ + fieldC4(name1: Boolean = true): String + + """ + fieldC5 tests parsing with an argument that has a default null value + """ + fieldC5(name1: Int = null): String + + """ + fieldC6 tests parsing with an argument that has a default enum value + """ + fieldC6(name1: enum1 = ENUMVALUE): String + + """ + fieldC7 tests parsing with an argument that has a default list value + """ + fieldC7(name1: [Int] = [1, 2]): String + + """ + fieldC8 tests parsing with an argument that has a default object value + """ + fieldC8(name1: input1 = {one: 1, two: 2}): String + + """ + fieldD1 tests parsing with an argument that has a list type + """ + fieldD1(name1: [input1]): String + + """ + fieldD2 tests parsing with an argument that has a list type + """ + fieldD2(name1: input1!): String + + """ + fieldD3 tests parsing with an argument that has a list type with non-null values + """ + fieldD3(name1: [input1!]): String +} \ No newline at end of file diff --git a/tests/fixtures/arguments.rst b/tests/fixtures/arguments.rst new file mode 100644 index 0000000..3c063c1 --- /dev/null +++ b/tests/fixtures/arguments.rst @@ -0,0 +1,62 @@ +.. gql:directive:: @directiveA1 on ARGUMENT_DEFINITION + +.. gql:type:: TestType + + .. gql:type:field:: fieldA1(name1: type1, name2: TestType): String + + fieldA1 tests parsing with multiple arguments + + :argument name1: name1 tests that arguments can be documented. + :argument name2: name2 tests that arguments can be documented. + + .. gql:type:field:: fieldB1(name1: type1 @directiveA1): String + + fieldB1 tests parsing with an argument directive + + .. gql:type:field:: fieldB2(name1: type1 @directiveA1(name1: 1, name2: 2)): String + + fieldB2 tests parsing with an argument directive that has const arguments + + .. gql:type:field:: fieldC1(name1: type1 = 600): String + + fieldC1 tests parsing with an argument that has a default integer value + + .. gql:type:field:: fieldC2(name1: type1 = 1.5): String + + fieldC2 tests parsing with an argument that has a default float value + + .. gql:type:field:: fieldC3(name1: type1 = "mystring"): String + + fieldC3 tests parsing with an argument that has a default string value + + .. gql:type:field:: fieldC4(name1: type1 = true): String + + fieldC4 tests parsing with an argument that has a default boolean value + + .. gql:type:field:: fieldC5(name1: type1 = null): String + + fieldC5 tests parsing with an argument that has a default null value + + .. gql:type:field:: fieldC6(name1: type1 = ENUMVALUE): String + + fieldC6 tests parsing with an argument that has a default enum value + + .. gql:type:field:: fieldC7(name1: type1 = [1, 2]): String + + fieldC7 tests parsing with an argument that has a default list value + + .. gql:type:field:: fieldC8(name1: type1 = {one: 1, two: 2}): String + + fieldC8 tests parsing with an argument that has a default object value + + .. gql:type:field:: fieldD1(name1: [TestType]): String + + fieldD1 tests parsing with an argument that has a list type + + .. gql:type:field:: fieldD2(name1: TestType!): String + + fieldD2 tests parsing with an argument that has a list type + + .. gql:type:field:: fieldD3(name1: [TestType!]): String + + fieldD3 tests parsing with an argument that has a list type with non-null values diff --git a/tests/fixtures/conf.py b/tests/fixtures/conf.py new file mode 100644 index 0000000..82fcb50 --- /dev/null +++ b/tests/fixtures/conf.py @@ -0,0 +1,29 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "simple" +copyright = "2023, Ashley Whetter" +author = "Ashley Whetter" +release = "0.1.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "graphql2sphinx", +] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "furo" +html_static_path = [] diff --git a/tests/fixtures/directives.graphql b/tests/fixtures/directives.graphql new file mode 100644 index 0000000..7b5ed44 --- /dev/null +++ b/tests/fixtures/directives.graphql @@ -0,0 +1,23 @@ +input input1 { + one: Int + two: Int +} + +""" +directive1 tests parsing the simplest possible directive definition +""" +directive @directive1 on SCHEMA + +""" +directive2 tests parsing with multiple type system directive locations +""" +directive @directive2 on FIELD_DEFINITION | ARGUMENT_DEFINITION + +""" +directive3 tests that arguments are parsed +""" +directive @directive3( + "name1 tests that arguments can be documented." + name1: input1 +) on SCALAR + diff --git a/tests/fixtures/directives.rst b/tests/fixtures/directives.rst new file mode 100644 index 0000000..ad3d9b5 --- /dev/null +++ b/tests/fixtures/directives.rst @@ -0,0 +1,16 @@ +Directives +========== + +.. gql:directive:: @directive1 on SCHEMA + + directive1 tests parsing the simplest possible directive definition + +.. gql:directive:: @directive2 on FIELD_DEFINITION | ARGUMENT_DEFINITION + + directive2 tests parsing with multiple type system directive locations + +.. gql:directive:: @directive3(name1: type1) on SCALAR + + directive3 tests that arguments are parsed + + :argument name1: name1 tests that arguments can be documented. \ No newline at end of file diff --git a/tests/fixtures/enums.graphql b/tests/fixtures/enums.graphql new file mode 100644 index 0000000..f24e0c3 --- /dev/null +++ b/tests/fixtures/enums.graphql @@ -0,0 +1,18 @@ +directive @directiveA on ENUM | ENUM_VALUE + +""" +enum1 tests parsing the simplest possible enum definition +""" +enum enum1 { + "enum1.value1 tests parsing the simplest possible enum value definition" + value1 +} + +""" +enum2 tests that directives are parsed +""" +enum enum2 @directiveA { + "enum2.value1 tests that directives are parsed" + value1 @directiveA +} + diff --git a/tests/fixtures/enums.rst b/tests/fixtures/enums.rst new file mode 100644 index 0000000..c276856 --- /dev/null +++ b/tests/fixtures/enums.rst @@ -0,0 +1,29 @@ +Enums +===== + +Directives +---------- + +.. gql:enum:: enum1 + + enum1 tests parsing the simplest possible enum definition + + .. gql:enum:value:: value1 + + enum1.value1 tests parsing the simplest possible enum value definition + +.. gql:enum:: enum2 @deprecated + + enum2 tests that directives are parsed + + .. gql:enum:value:: value1 @deprecated + + enum2.value1 tests that directives are parsed + + +Roles +----- + +:gql:enum:`enum1` + +:gql:enum:value:`enum1.value1` diff --git a/tests/fixtures/index.rst b/tests/fixtures/index.rst new file mode 100644 index 0000000..339f67c --- /dev/null +++ b/tests/fixtures/index.rst @@ -0,0 +1,9 @@ +.. toctree:: + arguments + directives + enums + inputs + interfaces + scalars + type_objects + unions diff --git a/tests/fixtures/inputs.graphql b/tests/fixtures/inputs.graphql new file mode 100644 index 0000000..e479c32 --- /dev/null +++ b/tests/fixtures/inputs.graphql @@ -0,0 +1,19 @@ +directive @directiveA on INPUT_OBJECT | INPUT_FIELD_DEFINITION + +""" +input1 tests parsing the simplest possible input definition +""" +input input1 { + "input1.field1 tests parsing the simplest possible input field definition" + field1: Float +} + +""" +input2 tests that directives are parsed +""" +input input2 @directiveA { + "input2.field1 tests that directives are parsed" + field1: Int @directiveA + "input2.field2 tests that default values are parsed" + field2: String = "defaultvaluefield2" +} \ No newline at end of file diff --git a/tests/fixtures/inputs.rst b/tests/fixtures/inputs.rst new file mode 100644 index 0000000..738276d --- /dev/null +++ b/tests/fixtures/inputs.rst @@ -0,0 +1,33 @@ +Inputs +====== + +Directives +---------- + +.. gql:input:: input1 + + input1 tests parsing the simplest possible input definition + + .. gql:input:field:: field1: Float + + input1.field1 tests parsing the simplest possible input field definition + +.. gql:input:: input2 @deprecated + + input2 tests that directives are parsed + + .. gql:input:field:: field1: Int @deprecated + + input2.field1 tests that directives are parsed + + .. gql:input:field:: field2: String = "defaultvaluefield2" + + input2.field2 tests that default values are parsed + + +Roles +----- + +:gql:input:`input1` + +:gql:input:field:`input1.field1` diff --git a/tests/fixtures/interfaces.graphql b/tests/fixtures/interfaces.graphql new file mode 100644 index 0000000..fd3881e --- /dev/null +++ b/tests/fixtures/interfaces.graphql @@ -0,0 +1,19 @@ +directive @directiveA on INTERFACE | FIELD_DEFINITION + +""" +interface1 tests parsing the simplest possible interface definition +""" +interface interface1 { + "interface1.field1 tests parsing the simplest possible interface field definition" + field1: String +} + +""" +interface2 tests that directives are parsed +""" +interface interface2 @directiveA { + "interface2.field1 tests that directives are parsed" + field1: Int @directiveA + "interface2.field2 tests that arguments are parsed" + field2(arg1: Int = 0): String +} \ No newline at end of file diff --git a/tests/fixtures/interfaces.rst b/tests/fixtures/interfaces.rst new file mode 100644 index 0000000..eb604f0 --- /dev/null +++ b/tests/fixtures/interfaces.rst @@ -0,0 +1,33 @@ +Interfaces +========== + +Directives +---------- + +.. gql:interface:: interface1 + + interface1 tests parsing the simplest possible interface definition + + .. gql:interface:field:: field1: String + + interface1.field1 tests parsing the simplest possible interface field definition + +.. gql:interface:: interface2 @deprecated + + interface2 tests that directives are parsed + + .. gql:interface:field:: field1: Int @deprecated + + interface2.field1 tests that directives are parsed + + .. gql:interface:field:: field2(arg1: Int = 0): String + + interface2.field2 tests that arguments are parsed + + +Roles +----- + +:gql:interface:`interface1` + +:gql:interface:field:`interface1.field1` diff --git a/tests/fixtures/scalars.graphql b/tests/fixtures/scalars.graphql new file mode 100644 index 0000000..6a6acb1 --- /dev/null +++ b/tests/fixtures/scalars.graphql @@ -0,0 +1,11 @@ +directive @directiveA on SCALAR + +""" +scalar1 tests parsing the simplest possible scalar definition +""" +scalar scalar1 + +""" +scalar2 tests that directives are parsed +""" +scalar scalar2 @directiveA \ No newline at end of file diff --git a/tests/fixtures/scalars.rst b/tests/fixtures/scalars.rst new file mode 100644 index 0000000..23f4b8f --- /dev/null +++ b/tests/fixtures/scalars.rst @@ -0,0 +1,20 @@ +Scalars +======= + +Directives +---------- + +.. gql:scalar:: scalar1 + + scalar1 tests parsing the simplest possible scalar definition + + +.. gql:scalar:: scalar2 @deprecated + + scalar2 tests that directives are parsed + + +Roles +----- + +:gql:scalar:`scalar1` diff --git a/tests/fixtures/type_objects.graphql b/tests/fixtures/type_objects.graphql new file mode 100644 index 0000000..4245e51 --- /dev/null +++ b/tests/fixtures/type_objects.graphql @@ -0,0 +1,19 @@ +directive @directiveA on OBJECT | FIELD_DEFINITION + +""" +type1 tests parsing the simplest possible type definition +""" +type type1 { + "type1.field1 tests parsing the simplest possible type field definition" + field1: Int +} + +""" +type2 tests that directives are parsed +""" +type type2 @directiveA { + "type2.field1 tests that directives are parsed" + field1: Int @directiveA + "type2.field2 tests that arguments are parsed" + field2(arg1: Int = 0): String +} \ No newline at end of file diff --git a/tests/fixtures/type_objects.rst b/tests/fixtures/type_objects.rst new file mode 100644 index 0000000..8c1219e --- /dev/null +++ b/tests/fixtures/type_objects.rst @@ -0,0 +1,33 @@ +Type Objects +============ + +Directives +---------- + +.. gql:type:: type1 + + type1 tests parsing the simplest possible type definition + + .. gql:type:field:: field1: Int + + type1.field1 tests parsing the simplest possible type field definition + +.. gql:type:: type2 @deprecated + + type2 tests that directives are parsed + + .. gql:type:field:: field1: Int @deprecated + + type2.field1 tests that directives are parsed + + .. gql:type:field:: field2(arg1: Int = 0): String + + type2.field2 tests that arguments are parsed + + +Roles +----- + +:gql:type:`type1` + +:gql:type:field:`type1.field1` diff --git a/tests/fixtures/unions.graphql b/tests/fixtures/unions.graphql new file mode 100644 index 0000000..0664180 --- /dev/null +++ b/tests/fixtures/unions.graphql @@ -0,0 +1,19 @@ +directive @directiveA on UNION + +type type1 { + field1: Int +} + +type type2 { + field1: Int +} + +""" +union1 tests parsing the simplest possible union definition +""" +union union1 = type1 | type2 + +""" +union2 tests that directives are parsed +""" +union union2 @directiveA = type1 | type2 \ No newline at end of file diff --git a/tests/fixtures/unions.rst b/tests/fixtures/unions.rst new file mode 100644 index 0000000..4074ee4 --- /dev/null +++ b/tests/fixtures/unions.rst @@ -0,0 +1,22 @@ +Unions +====== + +Directives +---------- + +.. gql:union:: union1 = Int + + union1 tests parsing the simplest possible union definition + +.. gql:union:: union2 = union1 | String + + union2 tests parsing multiple union members types + +.. gql:union:: union3 @deprecated = Int + + union3 tests that directives are parsed + +Roles +----- + +:gql:union:`union1` diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..5430f43 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,360 @@ +import os +import pathlib +import re +import shutil + +import bs4 +import pytest +from sphinx.application import Sphinx + + +def rebuild(**kwargs) -> None: + """Build the documentation. + + Documentation is output to ``./_build/html``. + """ + app = Sphinx( + srcdir=".", + confdir=".", + outdir="_build/html", + doctreedir="_build/.doctrees", + buildername="html", + warningiserror=True, + confoverrides={"suppress_warnings": ["app"]}, + **kwargs, + ) + app.build() + + +@pytest.fixture(scope="class") +def builder(tmp_path_factory): + cwd = pathlib.Path.cwd() + + def build(test_name, **kwargs): + dest = tmp_path_factory.mktemp(test_name) + test_file = pathlib.Path("tests") / "fixtures" / f"{test_name}.rst" + shutil.copy(test_file, dest / "index.rst") + shutil.copy(pathlib.Path("tests") / "fixtures" / "conf.py", dest) + os.chdir(dest) + rebuild(**kwargs) + + yield build + + os.chdir(cwd) + + +def signature_text(soup: bs4.BeautifulSoup): + # Strip the anchor character off the end + result = soup.get_text()[:-1] + # Strip the leading newline character that doesn't get displayed to users + result = result.strip() + # Condense double spaces created by HTML output quirks or `.get_text()` quirks + result = re.sub(" ", " ", result) + return result + + +class TestArguments: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("arguments") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_multiple_arguments(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldA1") + assert signature_text(sig) == "fieldA1(name1: type1, name2: TestType): String" + + def test_argument_fields(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldA1") + links = sig.parent.find("ul").find_all("li") + assert len(links) == 2 + enum_link, value_link = links + assert enum_link.get_text().startswith("name1") + assert value_link.get_text().startswith("name2") + + def test_type_is_linked(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldA1") + link = sig.a + assert link, "Argument type did not resolve to a valid hyperlink" + assert link.get_text() == "TestType" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldB1") + assert signature_text(sig) == "fieldB1(name1: type1 @directiveA1): String" + + def test_with_directive_const_arguments(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldB2") + assert ( + signature_text(sig) + == "fieldB2(name1: type1 @directiveA1(name1: 1, name2: 2)): String" + ) + + def test_directive_is_linked(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldB1") + link = sig.a + assert link, "Argument directive did not resolve to a valid hyperlink" + assert link.get_text() == "directiveA1" + + def test_with_default_int_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC1") + assert signature_text(sig) == "fieldC1(name1: type1 = 600): String" + + def test_with_default_float_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC2") + assert signature_text(sig) == "fieldC2(name1: type1 = 1.5): String" + + def test_with_default_string_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC3") + assert signature_text(sig) == 'fieldC3(name1: type1 = "mystring"): String' + + def test_with_default_boolean_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC4") + assert signature_text(sig) == "fieldC4(name1: type1 = true): String" + + def test_with_default_null_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC5") + assert signature_text(sig) == "fieldC5(name1: type1 = null): String" + + def test_with_default_enum_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC6") + assert signature_text(sig) == "fieldC6(name1: type1 = ENUMVALUE): String" + + def test_with_default_list_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC7") + assert signature_text(sig) == "fieldC7(name1: type1 = [1, 2]): String" + + def test_with_default_object_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldC8") + assert signature_text(sig) == "fieldC8(name1: type1 = {one: 1, two: 2}): String" + + def test_with_list_type(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldD1") + assert signature_text(sig) == "fieldD1(name1: [TestType]): String" + link = sig.a + assert link, "Nested type did not resolve to a valid hyperlink" + assert link.get_text() == "TestType" + + def test_with_non_null_type(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldD2") + assert signature_text(sig) == "fieldD2(name1: TestType!): String" + link = sig.a + assert link, "Non-null type did not resolve to a valid hyperlink" + assert link.get_text() == "TestType" + + def test_with_list_type_non_null_values(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="fieldD3") + assert signature_text(sig) == "fieldD3(name1: [TestType!]): String" + link = sig.a + assert link, "Nested non-null type did not resolve to a valid hyperlink" + assert link.get_text() == "TestType" + + +class TestDirectives: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("directives") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="directive1") + assert signature_text(sig) == "directive @directive1 on SCHEMA" + + def test_multi_location(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="directive2") + assert ( + signature_text(sig) + == "directive @directive2 on FIELD_DEFINITION | ARGUMENT_DEFINITION" + ) + + def test_with_argument(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="directive3") + assert signature_text(sig) == "directive @directive3(name1: type1) on SCALAR" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 1 + link = links[0] + assert link.get_text() == "directive1" + + +class TestEnums: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("enums") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="enum1") + assert signature_text(sig) == "enum enum1" + + sig = soup.find(id="enum1.value1") + assert signature_text(sig) == "value1" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="enum2") + assert signature_text(sig) == "enum enum2 @deprecated" + + sig = soup.find(id="enum2.value1") + assert signature_text(sig) == "value1 @deprecated" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 2 + enum_link, value_link = links + assert enum_link.get_text() == "enum1" + assert value_link.get_text() == "enum1.value1" + + +class TestInputs: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("inputs") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="input1") + assert signature_text(sig) == "input input1" + + sig = soup.find(id="input1.field1") + assert signature_text(sig) == "field1: Float" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="input2") + assert signature_text(sig) == "input input2 @deprecated" + + sig = soup.find(id="input2.field1") + assert signature_text(sig) == "field1: Int @deprecated" + + def test_with_default_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="input2.field2") + assert signature_text(sig) == 'field2: String = "defaultvaluefield2"' + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 2 + input_link, field_link = links + assert input_link.get_text() == "input1" + assert field_link.get_text() == "input1.field1" + + +class TestInterfaces: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("interfaces") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="interface1") + assert signature_text(sig) == "interface interface1" + + sig = soup.find(id="interface1.field1") + assert signature_text(sig) == "field1: String" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="interface2") + assert signature_text(sig) == "interface interface2 @deprecated" + + sig = soup.find(id="interface2.field1") + assert signature_text(sig) == "field1: Int @deprecated" + + def test_with_default_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="interface2.field2") + assert signature_text(sig) == "field2(arg1: Int = 0): String" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 2 + input_link, field_link = links + assert input_link.get_text() == "interface1" + assert field_link.get_text() == "interface1.field1" + + +class TestScalars: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("scalars") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="scalar1") + assert signature_text(sig) == "scalar scalar1" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="scalar2") + assert signature_text(sig) == "scalar scalar2 @deprecated" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 1 + link = links[0] + assert link.get_text() == "scalar1" + + +class TestTypeObjects: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("type_objects") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="type1") + assert signature_text(sig) == "type type1" + + sig = soup.find(id="type1.field1") + assert signature_text(sig) == "field1: Int" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="type2") + assert signature_text(sig) == "type type2 @deprecated" + + sig = soup.find(id="type2.field1") + assert signature_text(sig) == "field1: Int @deprecated" + + def test_with_default_value(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="type2.field2") + assert signature_text(sig) == "field2(arg1: Int = 0): String" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 2 + input_link, field_link = links + assert input_link.get_text() == "type1" + assert field_link.get_text() == "type1.field1" + + +class TestUnions: + @pytest.fixture(scope="class") + def soup(self, builder): + builder("unions") + with (pathlib.Path("_build") / "html" / "index.html").open() as in_f: + return bs4.BeautifulSoup(in_f, "html.parser") + + def test_simple_parse(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="union1") + assert signature_text(sig) == "union union1 = Int" + + def test_with_multiple_member_types(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="union2") + assert signature_text(sig) == "union union2 = union1 | String" + + def test_links_member_types(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="union2") + link = sig.a + assert link, "Nested type did not resolve to a valid hyperlink" + assert link.get_text() == "union1" + + def test_with_directive(self, soup: bs4.BeautifulSoup): + sig = soup.find(id="union3") + assert signature_text(sig) == "union union3 @deprecated = Int" + + def test_role(self, soup: bs4.BeautifulSoup): + links = soup.find(id="roles").find_all("a", "reference") + assert len(links) == 1 + link = links[0] + assert link.get_text() == "union1" diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..c74ea49 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,372 @@ +import pathlib + +from graphql2sphinx import _objects +from graphql2sphinx._parser import Parser + + +def parse(path): + with open(path, "r") as in_f: + return Parser().parse_from_source(in_f.read()) + + +class AlwaysEqual: + def __init__(self, _=None): + super().__init__() + + def __eq__(self, _): + return True + + +def test_argument_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "arguments.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + AlwaysEqual("directiveB"), + AlwaysEqual("enum1"), + AlwaysEqual("input1"), + _objects.GraphQLType( + [ + _objects.GraphQLTypeField( + [], + "fieldA1 tests parsing with multiple arguments", + "fieldA1(name1: Int, name2: input1): String", + 21, + ), + _objects.GraphQLTypeField( + [], + "fieldB1 tests parsing with an argument directive", + "fieldB1(name1: input1 @directiveA): String", + 31, + ), + _objects.GraphQLTypeField( + [], + "fieldB2 tests parsing with an argument directive that has const arguments", + "fieldB2(name1: input1 @directiveB(name1: 1, name2: 2)): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC1 tests parsing with an argument that has a default integer value", + "fieldC1(name1: Int = 600): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC2 tests parsing with an argument that has a default float value", + "fieldC2(name1: Float = 1.5): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC3 tests parsing with an argument that has a default string value", + 'fieldC3(name1: String = "mystring"): String', + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC4 tests parsing with an argument that has a default boolean value", + "fieldC4(name1: Boolean = true): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC5 tests parsing with an argument that has a default null value", + "fieldC5(name1: Int = null): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC6 tests parsing with an argument that has a default enum value", + "fieldC6(name1: enum1 = ENUMVALUE): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC7 tests parsing with an argument that has a default list value", + "fieldC7(name1: [Int] = [1, 2]): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldC8 tests parsing with an argument that has a default object value", + "fieldC8(name1: input1 = {one: 1, two: 2}): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldD1 tests parsing with an argument that has a list type", + "fieldD1(name1: [input1]): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldD2 tests parsing with an argument that has a list type", + "fieldD2(name1: input1!): String", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "fieldD3 tests parsing with an argument that has a list type with non-null values", + "fieldD3(name1: [input1!]): String", + AlwaysEqual(), + ), + ], + "A type to test different argument configurations", + "TestArgumentType", + 17, + ), + ], + None, + "" + ) + assert result == expected + +def test_directive_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "directives.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("input1"), + _objects.GraphQLDirective( + [], + "directive1 tests parsing the simplest possible directive definition", + "@directive1 on SCHEMA", + 6, + ), + _objects.GraphQLDirective( + [], + "directive2 tests parsing with multiple type system directive locations", + "@directive2 on FIELD_DEFINITION | ARGUMENT_DEFINITION", + AlwaysEqual(), + ), + _objects.GraphQLDirective( + [], + "directive3 tests that arguments are parsed", + "@directive3(name1: input1) on SCALAR", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_enum_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "enums.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + _objects.GraphQLEnum( + [ + _objects.GraphQLEnumValue( + [], + "enum1.value1 tests parsing the simplest possible enum value definition", + "value1", + 7, + ), + ], + "enum1 tests parsing the simplest possible enum definition", + "enum1", + 3, + ), + _objects.GraphQLEnum( + [ + _objects.GraphQLEnumValue( + [], + "enum2.value1 tests that directives are parsed", + "value1 @directiveA", + AlwaysEqual(), + ), + ], + "enum2 tests that directives are parsed", + "enum2 @directiveA", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_input_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "inputs.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + _objects.GraphQLInput( + [ + _objects.GraphQLInputField( + [], + "input1.field1 tests parsing the simplest possible input field definition", + "field1: Float", + 7, + ), + ], + "input1 tests parsing the simplest possible input definition", + "input1", + 3, + ), + _objects.GraphQLInput( + [ + _objects.GraphQLInputField( + [], + "input2.field1 tests that directives are parsed", + "field1: Int @directiveA", + AlwaysEqual(), + ), + _objects.GraphQLInputField( + [], + "input2.field2 tests that default values are parsed", + 'field2: String = "defaultvaluefield2"', + AlwaysEqual(), + ), + ], + "input2 tests that directives are parsed", + "input2 @directiveA", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_interface_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "interfaces.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + _objects.GraphQLInterface( + [ + _objects.GraphQLInterfaceField( + [], + "interface1.field1 tests parsing the simplest possible interface field definition", + "field1: String", + 7, + ), + ], + "interface1 tests parsing the simplest possible interface definition", + "interface1", + 3, + ), + _objects.GraphQLInterface( + [ + _objects.GraphQLInterfaceField( + [], + "interface2.field1 tests that directives are parsed", + "field1: Int @directiveA", + AlwaysEqual(), + ), + _objects.GraphQLInterfaceField( + [], + "interface2.field2 tests that arguments are parsed", + "field2(arg1: Int = 0): String", + AlwaysEqual(), + ), + ], + "interface2 tests that directives are parsed", + "interface2 @directiveA", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_scalar_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "scalars.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + _objects.GraphQLScalar( + [], + "scalar1 tests parsing the simplest possible scalar definition", + "scalar1", + 3, + ), + _objects.GraphQLScalar( + [], + "scalar2 tests that directives are parsed", + "scalar2 @directiveA", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_type_object_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "type_objects.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + _objects.GraphQLType( + [ + _objects.GraphQLTypeField( + [], + "type1.field1 tests parsing the simplest possible type field definition", + "field1: Int", + 7, + ), + ], + "type1 tests parsing the simplest possible type definition", + "type1", + 3, + ), + _objects.GraphQLType( + [ + _objects.GraphQLTypeField( + [], + "type2.field1 tests that directives are parsed", + "field1: Int @directiveA", + AlwaysEqual(), + ), + _objects.GraphQLTypeField( + [], + "type2.field2 tests that arguments are parsed", + "field2(arg1: Int = 0): String", + AlwaysEqual(), + ), + ], + "type2 tests that directives are parsed", + "type2 @directiveA", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected + + +def test_union_parsing(): + result = parse(pathlib.Path("tests") / "fixtures" / "unions.graphql") + expected = _objects.GraphQLSchema( + [ + AlwaysEqual("directiveA"), + AlwaysEqual("type1"), + AlwaysEqual("type2"), + _objects.GraphQLUnion( + [], + "union1 tests parsing the simplest possible union definition", + "union1 = type1 | type2", + 11, + ), + _objects.GraphQLUnion( + [], + "union2 tests that directives are parsed", + "union2 @directiveA = type1 | type2", + AlwaysEqual(), + ), + ], + None, + "" + ) + assert result == expected diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..e4e9c9b --- /dev/null +++ b/tox.ini @@ -0,0 +1,69 @@ +[tox] +isolated_build = true +envlist = + # Keep this in sync with .github/workflows/main.yml + py{38,39,310,311} + formatting + typecheck + lint + doc + release_notes + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311, formatting, typecheck, lint, doc, release_notes + +[testenv] +deps = + beautifulsoup4 + furo + pytest +commands = + pytest {posargs} + +[testenv:formatting] +skip_install = true +deps = + black +commands = + black {posargs:--check src tests} + +[testenv:lint] +deps = + ruff +commands = + ruff check {posargs:src} + +[testenv:typecheck] +deps = + mypy + types-docutils +commands = + mypy --strict {posargs:src} + +[testenv:doc] +changedir = {toxinidir}/doc/source +extras = + doc +deps = +commands = + sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html + +[testenv:release_notes] +deps = + towncrier +commands = + towncrier {posargs:check} + +[testenv:release] +skip_install = true +deps = + build + twine +commands = + python -c "import shutil, os; os.path.isdir('dist') and shutil.rmtree('dist')" + python -m build + twine upload dist/*