Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,40 @@ You can run the tests using pytest:
pytest
```

### Codegen from the GraphQL schema

We use code generation to create Python client code from our GraphQL schema. This involves one tools:

- [**ariadne-codegen**](https://github.com/mirumee/ariadne-codegen): Generates typed Python GraphQL client code from GraphQL files

The code generation process:

1. The GraphQL schema is manually taken from the [Openhexa Monorepo](https://github.com/BLSQ/openhexa-app/blob/main/frontend/schema.generated.graphql) and saved in [`openhexa/graphql/schema.generated.graphql`](https://github.com/BLSQ/openhexa-sdk-python/blob/main/openhexa/graphql/schema.generated.graphql)
2`ariadne-codegen` uses both the schema and queries to generate typed Python client code

To run code generation manually:

```shell
pip install ariadne-codegen
ariadne-codegen
```

ariadne-codegen runs automatically via pre-commit hooks and CI/CD when GraphQL files are modified.

You can add new queries or mutations in the [`openhexa/graphql/queries.graphql`](https://github.com/BLSQ/openhexa-sdk-python/blob/main/openhexa/graphql/queries.graphql) directory, and they will be picked up by the code generation process.

Example of usage of the generated code:

```python
from sdk import OpenHexaClient

# connect to OpenHEXA backend using environment variables
OpenHexaClient().get_countries(workspace_slug="workspace_slug_example")

# or explicitly pass the URL and token
OpenHexaClient(server_url="app.demo.openhexa.org", token="supersecuretoken")
```

## Release

This project uses [release-please](https://github.com/googleapis/release-please) to manage releases using conventional commits.
Expand Down
23 changes: 4 additions & 19 deletions openhexa/cli/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
from graphql.utilities import find_breaking_changes
from jinja2 import Template

from openhexa.cli.graphql.graphql_client import Client
from openhexa.cli.settings import settings
from openhexa.graphql import BaseOpenHexaClient
from openhexa.sdk.pipelines import get_local_workspace_config
from openhexa.sdk.pipelines.runtime import get_pipeline
from openhexa.utils import create_requests_session, stringcase
Expand Down Expand Up @@ -747,31 +747,16 @@ def is_dhis2_connection_up(workspace_slug: str, connection_slug: str) -> bool:
return response["data"]["connectionBySlug"]["status"] == "UP"


class OpenHexaClient(Client):
class OpenHexaClient(BaseOpenHexaClient):
"""OpenHexaClient is a class that provides methods to interact with the OpenHexa GraphQL API."""

def __init__(self, token=None):
"""Initialize the OpenHexaClient with the OpenHexa API URL and headers."""
self._url = settings.api_url + "/graphql/"
self._token = token or settings.access_token

if not self._token:
raise InvalidTokenError("No token found for workspace")

super().__init__(
url=self._url,
headers={
"User-Agent": f"openhexa-cli/{version('openhexa.sdk')}",
"Authorization": f"Bearer {self._token}",
},
)
logging.getLogger("httpx").setLevel(
logging.WARNING
) # HTTPX logs queries by default, we disable them here with WARNING level
super().__init__(url=settings.api_url + "/graphql/", token=settings.access_token)

def execute(self, query, **kwargs):
"""Decorate parent execute method to log the GraphQL query and response."""
_detect_graphql_breaking_changes(token=self._token)
_detect_graphql_breaking_changes(token=self.token)

if settings.debug:
click.echo("")
Expand Down
5 changes: 5 additions & 0 deletions openhexa/graphql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""GraphQL package."""


from .base_openhexa_client import BaseOpenHexaClient # noqa: F401 -> Expose base client class
from .graphql_client import * # noqa: F403 -> Expose autogenerated types
29 changes: 29 additions & 0 deletions openhexa/graphql/base_openhexa_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""OpenHexaClient implementation for GraphQL API interaction."""

import logging
from importlib.metadata import version

from openhexa.graphql.graphql_client import Client


class BaseOpenHexaClient(Client):
"""OpenHexaClient is a class that provides methods to interact with the OpenHexa GraphQL API."""

def __init__(self, url: str, token: str):
"""Initialize the OpenHexaClient with the OpenHexa API URL and headers.

Args:
url: GraphQL API URL.
token: Authentication token.
"""
self.token = token

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks for changing this!

super().__init__(
url=url,
headers={
"User-Agent": f"openhexa-sdk/{version('openhexa.sdk')}",
"Authorization": f"Bearer {self.token}",
},
)
logging.getLogger("httpx").setLevel(
logging.WARNING
) # HTTPX logs queries by default, we disable them here with WARNING level
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Generated by ariadne-codegen
# Source: openhexa/cli/graphql/queries.graphql
# Source: openhexa/graphql/queries.graphql

from typing import Any, Dict, Optional, Union

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Generated by ariadne-codegen
# Source: openhexa/cli/graphql/schema.generated.graphql
# Source: openhexa/graphql/schema.generated.graphql

from enum import Enum

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Generated by ariadne-codegen
# Source: openhexa/cli/graphql/queries.graphql
# Source: openhexa/graphql/queries.graphql

from typing import List, Optional

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Generated by ariadne-codegen
# Source: openhexa/cli/graphql/queries.graphql
# Source: openhexa/graphql/queries.graphql

from typing import Any, List, Optional

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Generated by ariadne-codegen
# Source: openhexa/cli/graphql/schema.generated.graphql
# Source: openhexa/graphql/schema.generated.graphql

from typing import Any, List, Optional

Expand Down
File renamed without changes.
2 changes: 2 additions & 0 deletions openhexa/sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .datasets import Dataset
from .pipelines import current_run, parameter, pipeline
from .pipelines.parameter import DHIS2Widget, IASOWidget
from .utils import OpenHexaClient
from .workspaces import workspace
from .workspaces.connection import (
CustomConnection,
Expand All @@ -27,4 +28,5 @@
"S3Connection",
"CustomConnection",
"Dataset",
"OpenHexaClient",
]
17 changes: 17 additions & 0 deletions openhexa/sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import typing

from openhexa.graphql import BaseOpenHexaClient
from openhexa.utils import create_requests_session


Expand Down Expand Up @@ -52,6 +53,22 @@ def graphql(operation: str, variables: dict[str | typing.Any] | None = None) ->
return body["data"]


class OpenHexaClient(BaseOpenHexaClient):
"""OpenHexaClient is a class that provides methods to interact with the OpenHexa GraphQL API."""

def __init__(self, token: str | None = None, server_url: str | None = None):
"""Initialize the OpenHexaClient with the OpenHexa API URL and headers.

Args:
token: Authentication token. If not provided, will use HEXA_TOKEN environment variable.
server_url: Server URL. If not provided, will use HEXA_SERVER_URL environment variable.
"""
url = server_url or f"{os.environ['HEXA_SERVER_URL'].rstrip('/')}/graphql/"
token = token or os.environ.get("HEXA_TOKEN")

super().__init__(url=url, token=token)


class Iterator(metaclass=abc.ABCMeta):
"""A generic class for iterating through API list responses."""

Expand Down
7 changes: 3 additions & 4 deletions openhexa/sdk/workspaces/current_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
from dataclasses import fields, make_dataclass
from warnings import warn

from openhexa.graphql.graphql_client import GetCountriesWorkspaceCountries
from openhexa.utils import stringcase

from ..datasets import Dataset
from ..utils import graphql
from ..utils import OpenHexaClient, graphql
from .connection import (
ConnectionClasses,
CustomConnection,
Expand Down Expand Up @@ -60,10 +61,8 @@ def slug(self) -> str:
raise WorkspaceConfigError("The workspace slug is not available in this environment.")

@property
def countries(self):
def countries(self) -> list[GetCountriesWorkspaceCountries]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we can use types without circular dependency 🎉

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"""The countries of the workspace."""
from openhexa.cli.api import OpenHexaClient

try:
return OpenHexaClient().get_countries(workspace_slug=self.slug).workspace.countries
except KeyError:
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ dependencies = [

[project.scripts]
openhexa = "openhexa.cli:app"
ariadne-codegen = "ariadne_codegen.cli:main"
ariadne-codegen = "ariadne_codegen.main:main"

[tool.ariadne-codegen]
schema_path = "openhexa/cli/graphql/schema.generated.graphql"
queries_path = "openhexa/cli/graphql/queries.graphql"
target_package_path = "openhexa/cli/graphql"
schema_path = "openhexa/graphql/schema.generated.graphql"
queries_path = "openhexa/graphql/queries.graphql"
target_package_path = "openhexa/graphql"
async_client = false

[project.optional-dependencies]
Expand Down Expand Up @@ -75,7 +75,7 @@ include-package-data = true
[tool.ruff]
line-length = 120
ignore = ["E501"]
exclude = ["openhexa/cli/graphql/graphql_client"]
exclude = ["openhexa/graphql/graphql_client"]
per-file-ignores = { "tests/**/test_*.py" = ["D100","D101","D102", "D103"] } # Ignore missing docstrings in tests

[tool.ruff.lint]
Expand Down