Skip to content

Commit

Permalink
Add GraphQL API server backend for the experimental-explorer goal (#1…
Browse files Browse the repository at this point in the history
…5697)

* Add explorer backend api server.

Start it with `./pants experimental-explorer` then go to http://localhost:8000/graphql for the
GraphQL UI, where queries may be executed interactively as well as browsing the Graph API
documentation.
  • Loading branch information
kaos authored Jun 8, 2022
1 parent 5f743d3 commit 7fafd7d
Show file tree
Hide file tree
Showing 25 changed files with 2,052 additions and 48 deletions.
3 changes: 3 additions & 0 deletions 3rdparty/python/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_requirements(
module_mapping={
"strawberry-graphql": ["strawberry"],
},
overrides={
"humbug": {"dependencies": ["#setuptools"]},
},
Expand Down
350 changes: 314 additions & 36 deletions 3rdparty/python/mypy.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions 3rdparty/python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ types-setuptools==57.4.7
types-toml==0.10.3
typing-extensions==4.0.1
mypy-typing-asserts==0.1.1


# These dependencies must only be used from the explorer backend, and no code outside that backend
# may import anything from it, so these libraries are not ending up as requirements of Pants itself.
fastapi==0.78.0
starlette==0.19.1
strawberry-graphql[fastapi]==0.114.0
uvicorn[standard]==0.17.6
943 changes: 933 additions & 10 deletions 3rdparty/python/user_reqs.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion build-support/bin/_release_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@

# Disable the Pants repository-internal internal_plugins.test_lockfile_fixtures plugin because
# otherwise inclusion of that plugin will fail due to its `pytest` import not being included in the pex.
#
# Disable the explorer backend, as that is packaged into a dedicated Python distribution and thus
# not included in the pex either.
DISABLED_BACKENDS_CONFIG = {
"PANTS_BACKEND_PACKAGES": '-["internal_plugins.test_lockfile_fixtures"]',
"PANTS_BACKEND_PACKAGES": '-["internal_plugins.test_lockfile_fixtures", "pants.backend.explorer"]',
}


Expand Down
2 changes: 2 additions & 0 deletions pants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pythonpath = ["%(buildroot)s/pants-plugins"]
backend_packages.add = [
"pants.backend.python",
"pants.backend.experimental.python.lint.autoflake",
"pants.backend.explorer",
"pants.backend.python.lint.black",
"pants.backend.python.lint.docformatter",
"pants.backend.python.lint.flake8",
Expand Down Expand Up @@ -174,6 +175,7 @@ extra_env_vars = [
interpreter_constraints = [">=3.7,<3.10"]
extra_requirements.add = [
"mypy-typing-asserts",
"strawberry-graphql>=0.95.1,<0.96",
]
lockfile = "3rdparty/python/mypy.lock"

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ omit = ["src/python/pants/__init__.py"]
namespace_packages = true
explicit_package_bases = true
mypy_path = "src/python:tests/python:testprojects/src/python"
plugins = "mypy_typing_asserts.mypy_plugin"
plugins = "mypy_typing_asserts.mypy_plugin, strawberry.ext.mypy_plugin"

no_implicit_optional = true
implicit_reexport = false
Expand Down
23 changes: 23 additions & 0 deletions src/python/pants/backend/explorer/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_sources()

python_distribution(
name="dist",
provides=python_artifact(
name="pantsbuild.pants.explorer-server",
description="Backend API server implementation for the Pants Explorer UI.",
classifiers=["Topic :: Software Development"],
),
entry_points={
"pantsbuild.plugin": {
"rules": "pants.backend.explorer.register:rules",
}
},
)

python_tests(
name="tests",
dependencies=[":explorer"],
)
54 changes: 54 additions & 0 deletions src/python/pants/backend/explorer/browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from dataclasses import dataclass

from pants.core.util_rules.system_binaries import OpenBinary
from pants.engine.explorer import RequestState
from pants.engine.process import Process, ProcessCacheScope, ProcessResult
from pants.engine.rules import QueryRule, collect_rules, rule
from pants.util.logging import LogLevel


@dataclass(frozen=True)
class Browser:
open_binary: OpenBinary
protocol: str
server: str

def open(self, request_state: RequestState, uri: str = "/") -> ProcessResult | None:
if not self.open_binary:
return None

url = f"{self.protocol}://{self.server}{uri}"
return request_state.product_request(
ProcessResult,
(
Process(
(self.open_binary.path, url),
description=f"Open {url} with default web browser.",
level=LogLevel.INFO,
cache_scope=ProcessCacheScope.PER_SESSION,
),
),
)


@dataclass(frozen=True)
class BrowserRequest:
protocol: str
server: str


@rule
async def get_browser(request: BrowserRequest, open_binary: OpenBinary) -> Browser:
return Browser(open_binary, request.protocol, request.server)


def rules():
return (
*collect_rules(),
QueryRule(ProcessResult, (Process,)),
)
4 changes: 4 additions & 0 deletions src/python/pants/backend/explorer/graphql/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_sources()
24 changes: 24 additions & 0 deletions src/python/pants/backend/explorer/graphql/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from dataclasses import dataclass
from typing import cast

from strawberry.types import Info

from pants.backend.explorer.server.uvicorn import UvicornServer
from pants.engine.explorer import RequestState


@dataclass(frozen=True)
class GraphQLContext:
uvicorn: UvicornServer

def create_request_context(self) -> dict[str, RequestState]:
return dict(pants_request_state=self.uvicorn.request_state)

@staticmethod
def request_state_from_info(info: Info) -> RequestState:
return cast(RequestState, info.context["pants_request_state"])
14 changes: 14 additions & 0 deletions src/python/pants/backend/explorer/graphql/field_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import json
from typing import NewType

import strawberry

JSONScalar = strawberry.scalar(
NewType("JSONScalar", object),
serialize=lambda v: v,
parse_value=lambda v: json.loads(v),
description="The GenericScalar scalar type represents a generic GraphQL scalar value.",
)
4 changes: 4 additions & 0 deletions src/python/pants/backend/explorer/graphql/query/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_sources()
14 changes: 14 additions & 0 deletions src/python/pants/backend/explorer/graphql/query/root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import strawberry

from pants.backend.explorer.graphql.query.rules import QueryRulesMixin
from pants.backend.explorer.graphql.query.targets import QueryTargetsMixin


@strawberry.type
class Query(QueryRulesMixin, QueryTargetsMixin):
"""Access to Pantsbuild data."""
79 changes: 79 additions & 0 deletions src/python/pants/backend/explorer/graphql/query/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import re
from dataclasses import asdict
from typing import Iterable, Iterator, List, Optional, cast

import strawberry
from strawberry.types import Info

from pants.backend.explorer.graphql.context import GraphQLContext
from pants.help import help_info_extracter


@strawberry.type(description=cast(str, help_info_extracter.RuleInfo.__doc__))
class RuleInfo:
name: str
description: Optional[str]
documentation: Optional[str]
provider: str
output_type: str
input_types: List[str]
input_gets: List[str]

@classmethod
def from_help(cls, info: help_info_extracter.RuleInfo) -> RuleInfo:
data = asdict(info)
return cls(**data)


@strawberry.input(
description="Filter rules based on name and/or limit the number of entries to return."
)
class RulesQuery:
name_re: Optional[str] = strawberry.field(
default=None, description="Select rules matching a regexp."
)
limit: Optional[int] = strawberry.field(
default=None, description="Limit the number of entries returned."
)

def __bool__(self) -> bool:
return not (self.name_re is None and self.limit is None)

@staticmethod
def filter(query: RulesQuery | None, rules: Iterable[RuleInfo]) -> Iterator[RuleInfo]:
if not query:
yield from rules
return

name_pattern = query.name_re and re.compile(query.name_re)
count = 0
for info in rules:
if name_pattern and not re.match(name_pattern, info.name):
continue
yield info
count += 1
if query.limit and count >= query.limit:
return


@strawberry.type
class QueryRulesMixin:
"""Get rules related info."""

@strawberry.field
def rules(self, info: Info, query: Optional[RulesQuery] = None) -> List[RuleInfo]:
request_state = GraphQLContext.request_state_from_info(info)
return list(
RulesQuery.filter(
query,
(
RuleInfo.from_help(info)
for info in request_state.all_help_info.name_to_rule_info.values()
),
)
)
Loading

0 comments on commit 7fafd7d

Please sign in to comment.