From 9cd4160e1be8cc333d0dcc39fd42280f08806aac Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Thu, 17 Apr 2025 16:29:55 +0200 Subject: [PATCH 1/7] Add configuration file to the project --- mapping_provider/config.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 mapping_provider/config.py diff --git a/mapping_provider/config.py b/mapping_provider/config.py new file mode 100644 index 0000000..978b201 --- /dev/null +++ b/mapping_provider/config.py @@ -0,0 +1,43 @@ +"""Configuration file for the Mapping Provider.""" + +import json + +import jsonschema + +CONFIG_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': { + 'aai': { + 'type': 'object', + 'properties': { + 'discovery_endpoint_url': {'type': 'string'}, + 'client_id': {'type': 'string'}, + 'secret': {'type': 'string'}, + }, + 'required': ['client_id', 'secret', 'discovery_endpoint_url'], + 'additionalProperties': False + }, + 'orchestrator': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + 'additionalProperties': False + }, + }, + 'required': ['aai', 'orchestrator'], + 'additionalProperties': False +} + + +def load(f): + """loads, validates and returns configuration parameters. + + :param f: file-like object that produces the config file + :return: + """ + config = json.loads(f.read()) + jsonschema.validate(config, CONFIG_SCHEMA) + return config -- GitLab From 7f35eef10ff247940bdb7d3c4dd12fbe59206cdf Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Fri, 18 Apr 2025 11:25:04 +0200 Subject: [PATCH 2/7] Load configuration file in app.config --- mapping_provider/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mapping_provider/__init__.py b/mapping_provider/__init__.py index aa309f9..32f6920 100644 --- a/mapping_provider/__init__.py +++ b/mapping_provider/__init__.py @@ -1,7 +1,9 @@ """Initializes the FastAPI application.""" +import os from fastapi import FastAPI +from mapping_provider import config from mapping_provider.api.common import router as version_router @@ -11,5 +13,10 @@ def create_app() -> FastAPI: title="Mapping provider", description="Mapping provider endpoints for GÉANT maps", ) + + config_file = os.environ.get("CONFIG_FILE_NAME", "config.json") + with config_file.open() as f: + app.state.config = config.load(f) + app.include_router(version_router) return app -- GitLab From 320c9f0caa19f6fe1ce61c23754f8ca2b3ac2e31 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 28 Apr 2025 13:35:12 +0200 Subject: [PATCH 3/7] Restructure FAST API app and load configuration into app --- mapping_provider/__init__.py | 16 ++++++++-------- mapping_provider/dependencies.py | 20 ++++++++++++++++++++ mapping_provider/main.py | 6 ------ 3 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 mapping_provider/dependencies.py delete mode 100644 mapping_provider/main.py diff --git a/mapping_provider/__init__.py b/mapping_provider/__init__.py index 32f6920..2fdc7a3 100644 --- a/mapping_provider/__init__.py +++ b/mapping_provider/__init__.py @@ -1,11 +1,9 @@ -"""Initializes the FastAPI application.""" -import os +"""Create a FastAPI application for the mapping provider.""" from fastapi import FastAPI -from mapping_provider import config from mapping_provider.api.common import router as version_router - +from mapping_provider.api.network_graph import router as graph_router def create_app() -> FastAPI: """Create a FastAPI application.""" @@ -14,9 +12,11 @@ def create_app() -> FastAPI: description="Mapping provider endpoints for GÉANT maps", ) - config_file = os.environ.get("CONFIG_FILE_NAME", "config.json") - with config_file.open() as f: - app.state.config = config.load(f) + # Force configuration to be loaded at startup to avoid issues with missing config + from mapping_provider.dependencies import load_config + _ = load_config() app.include_router(version_router) - return app + app.include_router(graph_router) + + return app \ No newline at end of file diff --git a/mapping_provider/dependencies.py b/mapping_provider/dependencies.py new file mode 100644 index 0000000..a776e79 --- /dev/null +++ b/mapping_provider/dependencies.py @@ -0,0 +1,20 @@ +"""FastAPI project dependencies.""" + +import os +from functools import lru_cache +from typing import Annotated + +from fastapi import Depends +from mapping_provider import config + + +@lru_cache() +def load_config() -> dict: + """Load and cache the application configuration.""" + config_file = os.environ.get("CONFIG_FILE_NAME", "config.json") + with open(config_file) as f: + return config.load(f) + + +# Dependency for injecting config into routes/services +config_dep = Annotated[dict, Depends(load_config)] \ No newline at end of file diff --git a/mapping_provider/main.py b/mapping_provider/main.py deleted file mode 100644 index dfcd3b4..0000000 --- a/mapping_provider/main.py +++ /dev/null @@ -1,6 +0,0 @@ -"""The main module that runs the application.""" - -from mapping_provider import create_app - -app = create_app() - -- GitLab From c41975a4297f73756e3368c4d476dabb2080fbe5 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 28 Apr 2025 13:36:02 +0200 Subject: [PATCH 4/7] Create app.py --- mapping_provider/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 mapping_provider/app.py diff --git a/mapping_provider/app.py b/mapping_provider/app.py new file mode 100644 index 0000000..3889cb0 --- /dev/null +++ b/mapping_provider/app.py @@ -0,0 +1,9 @@ +"""The main module that runs the application.""" + +import uvicorn +from mapping_provider import create_app + +app = create_app() + +if __name__ == "__main__": + uvicorn.run("mapping_provider.app:app", host="127.0.0.1", port=8000, reload=True) \ No newline at end of file -- GitLab From 9c801a1be220b1230a6a64e01523e859846d1767 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 28 Apr 2025 13:36:56 +0200 Subject: [PATCH 5/7] Add network-graph endpoint --- mapping_provider/api/network_graph.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 mapping_provider/api/network_graph.py diff --git a/mapping_provider/api/network_graph.py b/mapping_provider/api/network_graph.py new file mode 100644 index 0000000..616bef7 --- /dev/null +++ b/mapping_provider/api/network_graph.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from mapping_provider.dependencies import config_dep +from mapping_provider.services.gap import load_routers, load_trunks + +router = APIRouter() + + +@router.get("/network-graph") +def get_network_graph(config: config_dep): + routers = load_routers(config) + trunks = load_trunks(config) + return {"routers": routers, "trunks": trunks} -- GitLab From 34d60e170173c3b3e50456cb5bbda30ab792c51d Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 28 Apr 2025 15:21:54 +0200 Subject: [PATCH 6/7] Add GAP integration for getting the list of routers and trunks --- mapping_provider/api/network_graph.py | 4 +- mapping_provider/services/__init__.py | 1 + mapping_provider/services/gap.py | 146 ++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 mapping_provider/services/__init__.py create mode 100644 mapping_provider/services/gap.py diff --git a/mapping_provider/api/network_graph.py b/mapping_provider/api/network_graph.py index 616bef7..fa968b1 100644 --- a/mapping_provider/api/network_graph.py +++ b/mapping_provider/api/network_graph.py @@ -8,6 +8,4 @@ router = APIRouter() @router.get("/network-graph") def get_network_graph(config: config_dep): - routers = load_routers(config) - trunks = load_trunks(config) - return {"routers": routers, "trunks": trunks} + return {"routers": load_routers(config), "trunks": load_trunks(config)} diff --git a/mapping_provider/services/__init__.py b/mapping_provider/services/__init__.py new file mode 100644 index 0000000..e2df2d7 --- /dev/null +++ b/mapping_provider/services/__init__.py @@ -0,0 +1 @@ +"""External services for the mapping provider.""" \ No newline at end of file diff --git a/mapping_provider/services/gap.py b/mapping_provider/services/gap.py new file mode 100644 index 0000000..42010fb --- /dev/null +++ b/mapping_provider/services/gap.py @@ -0,0 +1,146 @@ +import logging +from collections.abc import Callable +from typing import Any + +import requests + +from mapping_provider.dependencies import config_dep + +logger = logging.getLogger(__name__) + +GRANT_TYPE = "client_credentials" +SCOPE = "openid profile email aarc" + + +def get_token_endpoint(discovery_endpoint_url: str, session: requests.Session) -> str: + """Fetch the token endpoint URL from discovery document.""" + response = session.get(discovery_endpoint_url) + response.raise_for_status() + return response.json()["token_endpoint"] + + +def get_token(config: dict, session: requests.Session) -> str: + """Retrieve an access token using client credentials flow.""" + aai_config = config["aai"] + token_endpoint = get_token_endpoint(aai_config["discovery_endpoint_url"], session) + response = session.post( + token_endpoint, + data={ + "grant_type": GRANT_TYPE, + "scope": SCOPE, + "client_id": aai_config["client_id"], + "client_secret": aai_config["secret"], + }, + ) + response.raise_for_status() + return response.json()["access_token"] + + +def make_request(query: str, token: str, config: dict, session: requests.Session) -> dict: + """Make a GraphQL request to the orchestrator API.""" + api_url = f"{config['orchestrator']['url']}/api/graphql" + headers = {"Authorization": f"Bearer {token}"} + response = session.post(api_url, headers=headers, json={"query": query}) + response.raise_for_status() + + data = response.json() + if "errors" in data: + logger.error(f"GraphQL query returned errors: {data['errors']}. Query: {query}") + raise ValueError(f"GraphQL query errors: {data['errors']}") + + return data + + +def extract_router(product_block_instances: list[dict]) -> str | None: + """Extract router FQDNs from productBlockInstances.""" + for instance in product_block_instances: + for value in instance.get("productBlockInstanceValues", []): + if value.get("field") == "routerFqdn" and value.get("value"): + return value["value"] + return None + + +def extract_trunk(product_block_instances: list[dict]) -> list[str] | None: + """Extract trunks from productBlockInstances.""" + fqdns = [] + for instance in product_block_instances: + for value in instance.get("productBlockInstanceValues", []): + if value.get("field") == "routerFqdn" and value.get("value"): + fqdns.append(value["value"]) + break + + if len(fqdns) >= 2: + return [fqdns[0], fqdns[1]] + return None + + +def load_inventory( + config: dict, + token: str, + tag: str, + session: requests.Session, + extractor: Callable[[list[dict]], Any | None], +) -> list[Any]: + """ + Generic function to load inventory items based on tag and extractor function. + + The extractor receives a list of productBlockInstances and returns parsed output. + """ + results = [] + end_cursor = 0 + has_next_page = True + + while has_next_page: + query = f""" + query {{ + subscriptions( + filterBy: {{field: "status", value: "PROVISIONING|ACTIVE"}}, + first: 100, + after: {end_cursor}, + query: "tag:({tag})" + ) {{ + pageInfo {{ + hasNextPage + endCursor + }} + page {{ + subscriptionId + product {{ + tag + }} + productBlockInstances {{ + productBlockInstanceValues + }} + }} + }} + }} + """ + + data = make_request(query, token, config, session) + page_data = data.get("data", {}).get("subscriptions", {}).get("page", []) + page_info = data.get("data", {}).get("subscriptions", {}).get("pageInfo", {}) + + for item in page_data: + instances = item.get("productBlockInstances", []) + extracted = extractor(instances) + if extracted: + results.append(extracted) + + has_next_page = page_info.get("hasNextPage", False) + end_cursor = page_info.get("endCursor", 0) + + return results + + +def load_routers(config: config_dep) -> list[str]: + """Load routers (nodes) from orchestrator.""" + with requests.Session() as session: + token = get_token(config, session) + return load_inventory(config, token, tag="RTR", session=session, extractor=extract_router) + + +def load_trunks(config: config_dep) -> list[list[str]]: + """Load trunks (edges) from orchestrator.""" + with requests.Session() as session: + token = get_token(config, session) + return load_inventory(config, token, tag="IPTRUNK", session=session, extractor=extract_trunk) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8cedf04..2f0ae84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ fastapi uvicorn[standard] +requests sphinx sphinx-rtd-theme -- GitLab From 4762a014eb90d0019144470b22b639c89e206baa Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 29 Apr 2025 10:53:58 +0200 Subject: [PATCH 7/7] Make ruff and mypy happy --- mapping_provider/__init__.py | 4 ++- mapping_provider/api/network_graph.py | 3 +- mapping_provider/app.py | 3 +- mapping_provider/config.py | 43 ++++++++++++++------------- mapping_provider/dependencies.py | 9 +++--- mapping_provider/services/__init__.py | 2 +- mapping_provider/services/gap.py | 33 ++++++++++---------- pyproject.toml | 14 +++++++-- requirements.txt | 2 ++ setup.py | 6 ++-- tox.ini | 27 +++++------------ 11 files changed, 78 insertions(+), 68 deletions(-) diff --git a/mapping_provider/__init__.py b/mapping_provider/__init__.py index 2fdc7a3..e21cff9 100644 --- a/mapping_provider/__init__.py +++ b/mapping_provider/__init__.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from mapping_provider.api.common import router as version_router from mapping_provider.api.network_graph import router as graph_router + def create_app() -> FastAPI: """Create a FastAPI application.""" app = FastAPI( @@ -14,9 +15,10 @@ def create_app() -> FastAPI: # Force configuration to be loaded at startup to avoid issues with missing config from mapping_provider.dependencies import load_config + _ = load_config() app.include_router(version_router) app.include_router(graph_router) - return app \ No newline at end of file + return app diff --git a/mapping_provider/api/network_graph.py b/mapping_provider/api/network_graph.py index fa968b1..3dcc926 100644 --- a/mapping_provider/api/network_graph.py +++ b/mapping_provider/api/network_graph.py @@ -7,5 +7,6 @@ router = APIRouter() @router.get("/network-graph") -def get_network_graph(config: config_dep): +def get_network_graph(config: config_dep) -> dict[str, list[str] | list[list[str]]]: + """Network graph endpoint.""" return {"routers": load_routers(config), "trunks": load_trunks(config)} diff --git a/mapping_provider/app.py b/mapping_provider/app.py index 3889cb0..44befa2 100644 --- a/mapping_provider/app.py +++ b/mapping_provider/app.py @@ -1,9 +1,10 @@ """The main module that runs the application.""" import uvicorn + from mapping_provider import create_app app = create_app() if __name__ == "__main__": - uvicorn.run("mapping_provider.app:app", host="127.0.0.1", port=8000, reload=True) \ No newline at end of file + uvicorn.run("mapping_provider.app:app", host="127.0.0.1", port=8000, reload=True) diff --git a/mapping_provider/config.py b/mapping_provider/config.py index 978b201..181badc 100644 --- a/mapping_provider/config.py +++ b/mapping_provider/config.py @@ -1,43 +1,44 @@ """Configuration file for the Mapping Provider.""" import json +from typing import Any, TextIO import jsonschema CONFIG_SCHEMA = { - '$schema': 'http://json-schema.org/draft-07/schema#', - 'type': 'object', - 'properties': { - 'aai': { - 'type': 'object', - 'properties': { - 'discovery_endpoint_url': {'type': 'string'}, - 'client_id': {'type': 'string'}, - 'secret': {'type': 'string'}, + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "aai": { + "type": "object", + "properties": { + "discovery_endpoint_url": {"type": "string"}, + "client_id": {"type": "string"}, + "secret": {"type": "string"}, }, - 'required': ['client_id', 'secret', 'discovery_endpoint_url'], - 'additionalProperties': False + "required": ["client_id", "secret", "discovery_endpoint_url"], + "additionalProperties": False, }, - 'orchestrator': { - 'type': 'object', - 'properties': { - 'url': {'type': 'string'}, + "orchestrator": { + "type": "object", + "properties": { + "url": {"type": "string"}, }, - 'required': ['url'], - 'additionalProperties': False + "required": ["url"], + "additionalProperties": False, }, }, - 'required': ['aai', 'orchestrator'], - 'additionalProperties': False + "required": ["aai", "orchestrator"], + "additionalProperties": False, } -def load(f): +def load(f: TextIO) -> dict[str, Any]: """loads, validates and returns configuration parameters. :param f: file-like object that produces the config file :return: """ - config = json.loads(f.read()) + config: dict[str, Any] = json.loads(f.read()) jsonschema.validate(config, CONFIG_SCHEMA) return config diff --git a/mapping_provider/dependencies.py b/mapping_provider/dependencies.py index a776e79..ac00f54 100644 --- a/mapping_provider/dependencies.py +++ b/mapping_provider/dependencies.py @@ -2,14 +2,15 @@ import os from functools import lru_cache -from typing import Annotated +from typing import Annotated, Any from fastapi import Depends + from mapping_provider import config -@lru_cache() -def load_config() -> dict: +@lru_cache +def load_config() -> dict[str, Any]: """Load and cache the application configuration.""" config_file = os.environ.get("CONFIG_FILE_NAME", "config.json") with open(config_file) as f: @@ -17,4 +18,4 @@ def load_config() -> dict: # Dependency for injecting config into routes/services -config_dep = Annotated[dict, Depends(load_config)] \ No newline at end of file +config_dep = Annotated[dict[str, Any], Depends(load_config)] diff --git a/mapping_provider/services/__init__.py b/mapping_provider/services/__init__.py index e2df2d7..450b588 100644 --- a/mapping_provider/services/__init__.py +++ b/mapping_provider/services/__init__.py @@ -1 +1 @@ -"""External services for the mapping provider.""" \ No newline at end of file +"""External services for the mapping provider.""" diff --git a/mapping_provider/services/gap.py b/mapping_provider/services/gap.py index 42010fb..4b05242 100644 --- a/mapping_provider/services/gap.py +++ b/mapping_provider/services/gap.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Any import requests +from requests import Session from mapping_provider.dependencies import config_dep @@ -12,14 +13,15 @@ GRANT_TYPE = "client_credentials" SCOPE = "openid profile email aarc" -def get_token_endpoint(discovery_endpoint_url: str, session: requests.Session) -> str: +def get_token_endpoint(discovery_endpoint_url: str, session: Session) -> str: """Fetch the token endpoint URL from discovery document.""" response = session.get(discovery_endpoint_url) response.raise_for_status() - return response.json()["token_endpoint"] + data: dict[str, Any] = response.json() + return str(data["token_endpoint"]) -def get_token(config: dict, session: requests.Session) -> str: +def get_token(config: dict[str, Any], session: Session) -> str: """Retrieve an access token using client credentials flow.""" aai_config = config["aai"] token_endpoint = get_token_endpoint(aai_config["discovery_endpoint_url"], session) @@ -33,17 +35,18 @@ def get_token(config: dict, session: requests.Session) -> str: }, ) response.raise_for_status() - return response.json()["access_token"] + data: dict[str, Any] = response.json() + return str(data["access_token"]) -def make_request(query: str, token: str, config: dict, session: requests.Session) -> dict: +def make_request(query: str, token: str, config: dict[str, Any], session: Session) -> dict[Any, Any]: """Make a GraphQL request to the orchestrator API.""" api_url = f"{config['orchestrator']['url']}/api/graphql" headers = {"Authorization": f"Bearer {token}"} response = session.post(api_url, headers=headers, json={"query": query}) response.raise_for_status() - data = response.json() + data: dict[str, Any] = response.json() if "errors" in data: logger.error(f"GraphQL query returned errors: {data['errors']}. Query: {query}") raise ValueError(f"GraphQL query errors: {data['errors']}") @@ -51,16 +54,16 @@ def make_request(query: str, token: str, config: dict, session: requests.Session return data -def extract_router(product_block_instances: list[dict]) -> str | None: +def extract_router(product_block_instances: list[dict[str, Any]]) -> str | None: """Extract router FQDNs from productBlockInstances.""" for instance in product_block_instances: for value in instance.get("productBlockInstanceValues", []): if value.get("field") == "routerFqdn" and value.get("value"): - return value["value"] + return str(value["value"]) return None -def extract_trunk(product_block_instances: list[dict]) -> list[str] | None: +def extract_trunk(product_block_instances: list[dict[str, Any]]) -> list[str] | None: """Extract trunks from productBlockInstances.""" fqdns = [] for instance in product_block_instances: @@ -75,11 +78,11 @@ def extract_trunk(product_block_instances: list[dict]) -> list[str] | None: def load_inventory( - config: dict, - token: str, - tag: str, - session: requests.Session, - extractor: Callable[[list[dict]], Any | None], + config: dict[str, Any], + token: str, + tag: str, + session: Session, + extractor: Callable[[list[dict[str, Any]]], Any | None], ) -> list[Any]: """ Generic function to load inventory items based on tag and extractor function. @@ -143,4 +146,4 @@ def load_trunks(config: config_dep) -> list[list[str]]: """Load trunks (edges) from orchestrator.""" with requests.Session() as session: token = get_token(config, session) - return load_inventory(config, token, tag="IPTRUNK", session=session, extractor=extract_trunk) \ No newline at end of file + return load_inventory(config, token, tag="IPTRUNK", session=session, extractor=extract_trunk) diff --git a/pyproject.toml b/pyproject.toml index cdeac56..b3e6aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] line-length = 120 -target-version = "py313" +target-version = "py312" select = ["E", "F", "I", "B", "UP", "N"] fixable = ["ALL"] exclude = ["tests", "docs", "build"] @@ -9,4 +9,14 @@ exclude = ["tests", "docs", "build"] python_version = "3.13" strict = true warn_unused_ignores = true -warn_return_any = true \ No newline at end of file +warn_return_any = true +allow_untyped_decorators = true +ignore_missing_imports = true +exclude = [ + "venv", + "test/*", + "docs" +] +[[tool.mypy.overrides]] +module = ["requests.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2f0ae84..05d5101 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ fastapi uvicorn[standard] requests +jsonschema +setuptools sphinx sphinx-rtd-theme diff --git a/setup.py b/setup.py index 5e5209c..32b9b6f 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,9 @@ setup( python_requires=">=3.10", install_requires=[ "fastapi", - "uvicorn[standard]" + "uvicorn[standard]", + "jsonschema", + "requests", ], long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", @@ -23,4 +25,4 @@ setup( "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], -) \ No newline at end of file +) diff --git a/tox.ini b/tox.ini index c34930b..23c347f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,11 @@ [tox] -envlist = lint, typecheck, docs +envlist = py313 -[testenv:lint] -description = Lint code with Ruff -deps = ruff -commands = ruff check mapping_provider - -[testenv:typecheck] -description = Type-check code with mypy -deps = mypy -commands = mypy mapping_provider - -[testenv:docs] -description = Build docs +[testenv] deps = - sphinx - sphinx-rtd-theme - sphinxcontrib-plantuml - sphinxcontrib-drawio - sphinxcontrib-openapi -commands = sphinx-build -b html docs/source docs/build - + -r requirements.txt +commands = + ruff check --respect-gitignore --preview . + ruff format --respect-gitignore --preview --check . + mypy . -- GitLab